<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ZyVOP]]></title><description><![CDATA[One platform to write, cross-post to Dev.to, Medium, WordPress, Hashnode, and Bluesky, and protect your SEO with canonical links. Zero paywalls and full content]]></description><link>https://blog.zyvop.com</link><image><url>https://cdn.hashnode.com/uploads/logos/6a1714c0badcd8afcb06dd34/9a30d9d1-b473-4627-ab43-c3e12f161a55.png</url><title>ZyVOP</title><link>https://blog.zyvop.com</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 26 Sep 2026 18:45:05 GMT</lastBuildDate><atom:link href="https://blog.zyvop.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Dozzle: The Complete Guide to Real-Time Docker Log Viewing]]></title><description><![CDATA[Written against Dozzle v11.1.x, September 2026. v11 shipped on September 11, so expect details to keep moving.
docker logs -f is fine for one container. Then you end up with a Compose stack of eight s]]></description><link>https://blog.zyvop.com/dozzle-the-complete-guide-to-real-time-docker-log-viewing</link><guid isPermaLink="true">https://blog.zyvop.com/dozzle-the-complete-guide-to-real-time-docker-log-viewing</guid><category><![CDATA[Devops]]></category><category><![CDATA[Docker]]></category><category><![CDATA[dozzle]]></category><category><![CDATA[Log Monitoring]]></category><category><![CDATA[SelfHosting]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Sat, 26 Sep 2026 08:37:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/d694ffd3-2ef2-44ee-949f-9a77a26fe212.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Written against Dozzle v11.1.x, September 2026. v11 shipped on September 11, so expect details to keep moving.</em></p>
<p><code>docker logs -f</code> is fine for one container. Then you end up with a Compose stack of eight services, or three hosts, and a bug that only shows up when the API, the worker, and the database are all unhappy at the same moment. Now you're flipping between terminal tabs, trying to line up timestamps by eye.</p>
<p>Dozzle solves that one problem. It's a small web app that shows container logs live in your browser: open the page, click a container, watch the lines arrive. This post goes from the two-minute install through search, alerts, multiple hosts, Kubernetes, and locking it down. It's based on v11.</p>
<h2>What it is, and what it isn't</h2>
<p>Dozzle is a live tail, nothing more. It doesn't store logs. It reads from the Docker API, the same place <code>docker logs</code> reads from, so what you see is whatever Docker still holds, and how much that is depends on your logging driver's rotation settings. Once Docker drops a line, Dozzle can't show it.</p>
<blockquote>
<p><strong>Keep in mind:</strong> Dozzle is a live viewer, not a log store. If you need history, it has to come from Docker's log settings or a separate logging stack.</p>
</blockquote>
<p>The upside of being that simple is that the image is only a few megabytes compressed and there's next to nothing to configure before logs appear. It works with Docker, Swarm, and Kubernetes, and with Colima and Podman too. Podman needs its remote socket enabled first.</p>
<p>The limits are worth knowing up front. The project says it's been tested with hundreds of containers, but it has no offline searching, and it points people who need full search toward tools like Loggly, Papertrail, or Kibana. Dozzle is for watching what's happening right now, not for digging through last week.</p>
<h2>What changed in v10 and v11</h2>
<p>A few things worth knowing if you last used Dozzle a while ago:</p>
<ul>
<li><p>v10 introduced alerts with webhook delivery. Today they cover logs, resource metrics, and container events.</p>
</li>
<li><p>v11 is the biggest visual overhaul so far: flat, neutral panels, with color saved for things that need your attention. It also brought GitHub and OIDC sign-in, recognition of more log formats, and alerts that persist across reloads.</p>
</li>
<li><p>v11.1 added a separate <code>oidc</code> auth provider that reads users and roles from the token, a login-first setup wizard for fresh installs, and <code>generate-certs</code> for giving agents their own certificate.</p>
</li>
</ul>
<p>One upgrade catch: session tokens are now signed with a random secret kept in the data directory, so everyone gets signed out once after upgrading.</p>
<h2>Quick start</h2>
<p>The one-liner:</p>
<pre><code class="language-bash">docker run -d --name dozzle \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v dozzle_data:/data \
  -p 8080:8080 \
  amir20/dozzle:latest
</code></pre>
<p>Open <code>http://localhost:8080</code> and your containers should be listed. For something you plan to keep running, a Compose file is easier to maintain:</p>
<pre><code class="language-yaml">services:
  dozzle:
    image: amir20/dozzle:latest   # pin a specific version tag in production
    container_name: dozzle
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./dozzle-data:/data
    environment:
      DOZZLE_NO_ANALYTICS: "true"
</code></pre>
<p>Some notes on that file:</p>
<ul>
<li><p>Mount <code>/data</code>. Alert and destination settings are stored there, so without a volume they vanish on restart. User settings and your <code>users.yml</code> live there too.</p>
</li>
<li><p>Dozzle sends anonymous usage analytics by default. <code>DOZZLE_NO_ANALYTICS</code> turns that off.</p>
</li>
<li><p>Pin the image tag. With Dozzle moving fast (v11 signed everyone out on upgrade), <code>latest</code> can bite you at a bad time.</p>
</li>
</ul>
<blockquote>
<p><strong>Two habits worth having from day one:</strong> mount <code>/data</code> so your settings survive restarts, and pin the image tag instead of using <code>latest</code>.</p>
</blockquote>
<h2>Getting around the interface</h2>
<p>The sidebar lists your containers and groups Compose services by stack name automatically. v11 rebuilt it around collapsible groups with counts, and each container's icon carries a status badge. Container names are fuzzy-searchable, so on a busy host you type a few letters and jump straight to the service.</p>
<p>Logs stream in the main pane. Dozzle detects JSON logs and pretty-prints them, and if your entries have a <code>level</code> field they're colored by severity. In v11, warn and error rows get a light tint so they stand out as you scroll, and a live indicator plus a floating scroll readout show where you are in the container's lifetime. If you only care about problems, one click hides the info and debug lines.</p>
<p>Split view is the feature that actually replaces terminal tabs. It puts several containers side by side, so when the API returns a 500 you can watch the database and cache logs at the same timestamp. In v11 the pinned columns are stored in the URL, which means a side-by-side view is just a link you can send to a teammate.</p>
<p>Each container also gets small CPU and memory charts. They're basic, but enough to tell whether a container is struggling.</p>
<h2>Searching and querying logs</h2>
<p>For quick filtering there's regex search over the logs. For anything more analytical there's a SQL engine.</p>
<p>The SQL engine runs DuckDB compiled to WebAssembly inside your browser, so your logs never leave your machine. Dozzle loads your JSON logs into a virtual <code>logs</code> table that you can query. You open it from the menu or with Ctrl/Cmd+Shift+F, and it only works on JSON-structured logs. The docs still label it beta.</p>
<p>It queries what's already loaded in the browser, not Docker's full history. That makes it good for ad-hoc debugging, but don't expect trend analysis from it. WebAssembly caps it at 4 GB of memory, and if you run out you refresh the page.</p>
<pre><code class="language-sql">-- How noisy is each severity right now?
SELECT level, COUNT(*) AS n
FROM logs
GROUP BY level;

-- Slowest failing requests (field names depend on your JSON logs)
SELECT message.path, message.status, message.duration
FROM logs
WHERE message.status &gt;= 500
ORDER BY message.duration DESC
LIMIT 20;

-- Errors per minute
SELECT date_trunc('minute', timestamp) AS minute, COUNT(*) AS error_count
FROM logs
WHERE level = 'error'
GROUP BY minute
ORDER BY minute DESC;
</code></pre>
<p>If you already emit structured logs, this can replace a lot of <code>docker logs | jq | grep</code> pipelines.</p>
<h2>Grouping and naming containers</h2>
<p>Dozzle groups by stack by default. To make your own groups, add the <code>dev.dozzle.group</code> label, and containers that share a group name end up together in the UI. There's also a <code>dev.dozzle.name</code> label if you want a friendlier display name.</p>
<pre><code class="language-yaml">services:
  api:
    image: myorg/api:1.4.2
    labels:
      dev.dozzle.group: shop
      dev.dozzle.name: shop-api
</code></pre>
<p>Under Swarm, if Dozzle sees the service-name label, it switches to a swarm view that joins all tasks of the same service.</p>
<h2>Limiting what Dozzle can see</h2>
<p><code>DOZZLE_FILTER</code> restricts which containers Dozzle can see at all. Filters are passed straight to Docker, in the same style as <code>docker ps --filter</code>, so <code>DOZZLE_FILTER=label=color</code> shows only containers that carry that label. They can also be set per agent and per user, and they stack: a container has to match all of them to show up.</p>
<p>Be careful with filters that exclude stopped containers, like <code>status=running</code>. The container that just crashed is often the one you need to read, and a filter like that hides it completely.</p>
<h2>Security</h2>
<p>Mounting the Docker socket gives a container effectively root-level access to the host, and the <code>:ro</code> in the examples above doesn't change that. It only marks the socket file read-only on disk, so API calls still pass through and create, delete, and update operations stay possible. If you don't need actions, put a socket proxy such as <code>tecnativa/docker-socket-proxy</code> between Dozzle and the daemon to limit what it can do.</p>
<p>An unauthenticated Dozzle on a reachable network also shows every container's logs to anyone who finds it, and logs often contain tokens and personal data.</p>
<blockquote>
<p><strong>Rule of thumb:</strong> no authentication, no exposure beyond localhost.</p>
</blockquote>
<h3>Built-in auth</h3>
<p>Start by generating a users file:</p>
<pre><code class="language-bash">docker run -it --rm amir20/dozzle generate admin \
  --password 'change-me' \
  --email admin@example.com \
  --name "Admin" &gt; users.yml
</code></pre>
<p>Put <code>users.yml</code> in your mounted <code>/data</code> directory and set <code>DOZZLE_AUTH_PROVIDER: simple</code>. Passwords are stored bcrypt-hashed. Each user can also have a <code>filter</code>, which restricts which containers they can see by label, and <code>roles</code>, which control what they can do: <code>shell</code>, <code>actions</code>, <code>download</code>, <code>notifications</code>, and <code>cloud</code>. A user with no roles listed gets all of them, so set roles explicitly for anyone who shouldn't have full access. The instance-wide flags for shell and actions still have to be on before those roles do anything.</p>
<h3>GitHub and OIDC (v11)</h3>
<p>v11 lets you sign in with GitHub or any OIDC provider, such as Authentik, Keycloak, Pocket ID, or Google. It sits on top of the <code>simple</code> provider, so <code>users.yml</code> stays the allowlist, no accounts are created automatically, and password login keeps working. If you'd rather manage users and roles in your identity provider, v11.1 added a separate <code>oidc</code> provider that reads them from the token.</p>
<pre><code class="language-yaml">environment:
  DOZZLE_AUTH_PROVIDER: simple
  DOZZLE_AUTH_GITHUB_CLIENT_ID: &lt;your-client-id&gt;
  DOZZLE_AUTH_GITHUB_CLIENT_SECRET: &lt;your-client-secret&gt;
</code></pre>
<h3>Forward-proxy auth</h3>
<p>In production, Dozzle can trust identity headers from a proxy like Authelia, Authentik, or Cloudflare Access. That's the better route if you want centralized multi-factor auth, but it comes with one hard rule: Dozzle believes the <code>Remote-User</code> header on every request. Publish only the proxy and keep Dozzle on an internal network (<code>expose</code>, not <code>ports</code>), because anyone who can reach Dozzle directly can set that header and log in as whoever they like. Also map roles from your proxy, for example <code>DOZZLE_AUTH_HEADER_ROLES: Remote-Groups</code> for Authelia groups, since without a mapping every authenticated user gets all roles.</p>
<h3>Actions and shell are opt-in</h3>
<p>Container start/stop/restart actions (<code>DOZZLE_ENABLE_ACTIONS</code>) and shell access (<code>DOZZLE_ENABLE_SHELL</code>) are off by default. If you turn either on, get authentication in place first. They give the web UI the same power as <code>docker stop</code> and <code>docker exec</code>.</p>
<h3>Reverse proxy</h3>
<p>Dozzle streams logs over Server-Sent Events and uses WebSockets for shell and attach. That gives a reverse proxy three jobs: don't buffer responses, forward the WebSocket upgrade headers, and don't compress <code>text/event-stream</code>. Buffering makes logs arrive in bursts or not at all. A minimal nginx location:</p>
<pre><code class="language-nginx">location / {
    proxy_pass http://127.0.0.1:8080;

    chunked_transfer_encoding off;
    proxy_buffering off;
    proxy_cache off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
}
</code></pre>
<p>The long read timeout matters too, because logs stop after a few seconds when the proxy's timeouts are short. Behind Traefik, the default <code>compress</code> middleware breaks SSE, so exclude <code>text/event-stream</code>. In Caddy, <code>flush_interval -1</code> turns off response buffering. And if you mount Dozzle under a sub-path with <code>DOZZLE_BASE</code>, make sure the proxy passes the full path through instead of stripping the prefix.</p>
<blockquote>
<p><strong>Proxy tip:</strong> if logs arrive in bursts or not at all, response buffering is the first thing to turn off.</p>
</blockquote>
<h3>Keep it updated</h3>
<p>Dozzle's security page lists several advisories from 2026, including these high-severity ones:</p>
<ul>
<li><p>an unauthenticated SSRF through the webhook test endpoint on default deployments without auth</p>
</li>
<li><p>cross-site WebSocket hijacking on the exec and attach endpoints, which got around authentication for setups with shell enabled (versions up to 10.5.1)</p>
</li>
<li><p>a label-based access bypass in the agent that allowed unauthorized shell access</p>
</li>
</ul>
<p>So: turn on auth, keep the container patched, and keep it off the open internet.</p>
<h2>Monitoring multiple hosts with agents</h2>
<p>To see several machines in one UI, run Dozzle in agent mode on each remote host and point a central instance (the hub) at them. Agents listen on port 7007, and the hub connects to them over TLS.</p>
<pre><code class="language-yaml"># On each remote host
services:
  dozzle-agent:
    image: amir20/dozzle:latest
    command: agent
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "7007:7007"   # keep this on a private network
</code></pre>
<pre><code class="language-yaml"># On the central host
services:
  dozzle:
    image: amir20/dozzle:latest
    volumes:
      - ./data:/data
    ports:
      - "8080:8080"
    environment:
      DOZZLE_AUTH_PROVIDER: simple   # expects users.yml in ./data
      DOZZLE_REMOTE_AGENT: "10.0.1.10:7007|web-1|production,10.0.1.11:7007|web-2|production"
</code></pre>
<p>The connection string looks like <code>endpoint|name|group</code>. All three parts are optional, and groups show up as collapsible sections in the sidebar, each with a button that merges the group's logs into one view. If the hub only needs to show remote hosts, you can skip mounting the local socket there. If you run Swarm, you don't need agents at all, because Dozzle discovers the cluster on its own.</p>
<p>Treat the agent port as sensitive. The TLS certificate Dozzle ships with is identical in every copy of the image, so it encrypts the connection but doesn't prove who is on the other end. Anyone who can reach port 7007 can connect their own Dozzle to your agent, read every log on that host, and run commands inside its containers. The agent also ignores <code>DOZZLE_ENABLE_SHELL</code> and <code>DOZZLE_ENABLE_ACTIONS</code>, because those flags only control what the UI offers. Keep 7007 on a private network (on a shared Docker network you don't need to publish it at all), and if anything you don't control can reach it, generate your own certificate with <code>generate-certs</code> so agents only accept your hub.</p>
<blockquote>
<p><strong>Important:</strong> anyone who can reach port 7007 can read every log and run commands inside that host's containers. Keep it on a private network.</p>
</blockquote>
<h2>Alerts</h2>
<p>Since v10, Dozzle can tell you when something breaks instead of waiting for you to notice. It watches logs, resource metrics, and lifecycle events, evaluates your rules on your own instance, and sends notifications to a webhook, Slack, Discord, or ntfy.</p>
<p>Each alert has a container expression, which decides which containers to watch, and a trigger expression. Triggers come in three types: log, metric, and event. Setup lives on the Notifications page: add a destination first, then create rules. Webhook destinations come with built-in Slack, Discord, and ntfy payloads, and you can write custom Go <code>text/template</code> payloads for anything else. There's a Test button, so you can confirm delivery before saving.</p>
<p>Some example rules, written in the expression style the docs use:</p>
<pre><code class="language-markdown"># 5xx responses from production APIs
Container: name contains "api" &amp;&amp; labels["env"] == "production"
Log:       message.status &gt;= 500

# Memory pressure on the database
Container: name == "postgres"
Metric:    memory &gt; 85

# Any OOM kill, anywhere
Container: true
Event:     name == "oom"
</code></pre>
<p>Metric alerts evaluate a smoothed average over a sample window and have a cooldown between triggers, so a brief spike doesn't flood your channel. For die events, the docs' example excludes exit codes 0, 130, 143, and 137, since those show up on routine stops and update cycles.</p>
<p>Dozzle Cloud is optional. Your rules always live on your self-hosted instance, but if you link it, delivery features such as grouping repeated failures, summaries, muting, and mobile channels are configured there.</p>
<p>Alerts are deliberately simple. There are no escalation policies or on-call rotations, so treat them as a safety net for staging and homelabs, not as a production pager.</p>
<h2>Kubernetes</h2>
<p>For Kubernetes, run Dozzle with <code>DOZZLE_MODE=k8s</code>. The docs include a full RBAC manifest; at minimum it needs read access to pods, pod logs, and nodes. Logs work without the Kubernetes Metrics API (metrics-server), but CPU and memory stay empty without it. Give it a persistent volume for <code>/data</code> so your alert config survives restarts.</p>
<pre><code class="language-yaml">env:
  - name: DOZZLE_MODE
    value: "k8s"
  - name: DOZZLE_NAMESPACE
    value: "prod,staging"   # optional; defaults to all namespaces
  - name: DOZZLE_FILTER
    value: "env=prod"       # optional label filter
</code></pre>
<p>The docs still call Kubernetes support a newer feature that may have limitations compared to the Docker version, and the release notes bear that out. v11.1.1 alone includes Kubernetes hardening, alerts for CronJob pods, and fixes for duplicate ReplicaSets and finished Jobs. If you run Dozzle on Kubernetes, keep it up to date.</p>
<h2>Letting AI assistants read your logs (MCP)</h2>
<p>Dozzle can expose an MCP endpoint so coding assistants can inspect your containers. It's disabled by default. Enable it with <code>DOZZLE_ENABLE_MCP=true</code> and it's served at <code>/api/mcp</code> from the same container. Every tool is read-only: listing containers and hosts, fetching and searching logs, and pulling CPU and memory history.</p>
<p>One warning: with no auth provider configured, the endpoint is publicly accessible, so set up authentication first. Once auth is on, MCP clients have to present credentials too.</p>
<h2>When your app logs to files instead of stdout</h2>
<p>Dozzle only sees what Docker captures, which means stdout and stderr, exactly like <code>docker logs</code>. Files inside a container are invisible to it.</p>
<p>The best fix is to log to the console, or symlink the log file to <code>/dev/stdout</code>, as the official nginx image does. If you can't, the docs suggest a small sidecar that tails the file:</p>
<pre><code class="language-bash">docker run -d --name app-log --network none \
  --label dev.dozzle.name=app-log \
  --log-opt max-size=10m --log-opt max-file=3 \
  -v /var/log/myapp:/logs:ro \
  alpine tail -n 1000 -F /logs/app.log
</code></pre>
<p>Use <code>-F</code> instead of <code>-f</code> so the tail reopens the path after log rotation. Mount the directory, not the single file, because a single-file bind mount stays attached to the old inode.</p>
<h2>Troubleshooting</h2>
<ul>
<li><p>Empty stream for a container that's clearly running: if it uses a remote logging driver such as splunk, fluentd, or awslogs, check whether <code>cache-disabled</code> is set to true (and look at <code>daemon.json</code> too). That setting blocks the local cache Dozzle reads from.</p>
</li>
<li><p>Logs arrive in bursts, or stop after a few seconds, behind a proxy: response buffering is on, <code>text/event-stream</code> is being compressed, or the read timeout is too short. See the reverse proxy section.</p>
</li>
<li><p>Shell disconnects immediately: the proxy isn't forwarding the WebSocket upgrade headers.</p>
</li>
<li><p>Won't start after following an old tutorial: <code>DOZZLE_USERNAME</code> and <code>DOZZLE_PASSWORD</code> are no longer supported. Use <code>users.yml</code> instead.</p>
</li>
<li><p>Alerts vanish after a restart: <code>/data</code> isn't mounted as a volume.</p>
</li>
<li><p>Signed out on every restart: if <code>/data</code> isn't writable, Dozzle falls back to an in-memory session secret (and warns about it), so sessions drop whenever it restarts.</p>
</li>
<li><p>Everyone logged out after upgrading to v11: expected, and it only happens once.</p>
</li>
</ul>
<h2>When to outgrow Dozzle</h2>
<p>Dozzle answers "what is this container saying right now?" It can't answer <em>which deploy introduced this spike</em>, <em>did the error rate stay high overnight</em>, or <em>what happened to this request across three services last week</em>. Those need retention, correlation, and analysis over time, which a real-time viewer doesn't give you. When you reach that point, add a proper logging or observability stack, like Loki, an OpenTelemetry pipeline, or a hosted platform, and keep Dozzle for the quick look.</p>
<h2>Checklist before you rely on it</h2>
<ol>
<li><p>Pin the image version and update on a schedule.</p>
</li>
<li><p>Mount <code>/data</code> as a persistent volume.</p>
</li>
<li><p>Turn on authentication (<code>users.yml</code>, OIDC/GitHub, or a forward proxy) before exposing it beyond localhost.</p>
</li>
<li><p>Leave actions and shell off unless you need them, and put a socket proxy in front of the Docker socket if you don't need actions.</p>
</li>
<li><p>For multiple hosts, use agents instead of exposing a Docker socket, keep port 7007 on a private network, and generate your own agent certificate if it's reachable from anywhere else.</p>
</li>
<li><p>Set log rotation (<code>max-size</code>, <code>max-file</code>) so there's enough history to look at.</p>
</li>
<li><p>Log to stdout, in JSON if you can, so you get level coloring, SQL queries, and structured alerts.</p>
</li>
</ol>
<h2>References</h2>
<ul>
<li><p><a href="https://dozzle.dev/guide/what-is-dozzle">Dozzle docs</a> and <a href="https://dozzle.dev/guide/whats-new">What's New in v11</a></p>
</li>
<li><p><a href="https://dozzle.dev/guide/alerts-and-webhooks">Alerts</a>, <a href="https://dozzle.dev/guide/agent">Agent Mode</a>, and <a href="https://dozzle.dev/guide/k8s">Kubernetes</a></p>
</li>
<li><p><a href="https://dozzle.dev/guide/changing-base">Reverse Proxy &amp; Base Path</a>, <a href="https://dozzle.dev/guide/authentication/simple">Simple authentication</a>, and <a href="https://dozzle.dev/guide/filters">Filters</a></p>
</li>
<li><p><a href="https://dozzle.dev/guide/sql-engine">SQL Engine</a>, <a href="https://dozzle.dev/guide/mcp">MCP Integration</a>, and <a href="https://dozzle.dev/guide/log-files-on-disk">Log Files on Disk</a></p>
</li>
<li><p><a href="https://dozzle.dev/guide/container-groups">Container Groups</a> and <a href="https://dozzle.dev/guide/supported-env-vars">supported environment variables</a></p>
</li>
<li><p><a href="https://dozzle.dev/guide/authentication">Authentication</a>, <a href="https://dozzle.dev/guide/authentication/forward-proxy">Forward Proxy</a>, and Docker's <a href="https://docs.docker.com/engine/logging/dual-logging">dual logging</a> docs</p>
</li>
<li><p><a href="https://github.com/amir20/dozzle/security">Security advisories</a> and <a href="https://github.com/amir20/dozzle/releases">release notes</a></p>
</li>
</ul>
<hr />
<p><em>Published via <a href="https://zyvop.com/dozzle-the-complete-guide-to-real-time-docker-log-viewing-ie1xx?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[We Added Redis to Make Things Faster. It Made Things Worse.]]></title><description><![CDATA[A product endpoint is taking around 800ms under load.
The request isn't doing anything unusual: fetch a product from PostgreSQL, serialize it, return JSON. The data changes infrequently, but the same ]]></description><link>https://blog.zyvop.com/we-added-redis-to-make-things-faster-it-made-things-worse</link><guid isPermaLink="true">https://blog.zyvop.com/we-added-redis-to-make-things-faster-it-made-things-worse</guid><category><![CDATA[backend]]></category><category><![CDATA[caching]]></category><category><![CDATA[distributedsystems]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Sat, 26 Sep 2026 06:51:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/00fe6c05-d7cd-4db7-a03b-2e3045f61982.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A product endpoint is taking around 800ms under load.</p>
<p>The request isn't doing anything unusual: fetch a product from PostgreSQL, serialize it, return JSON. The data changes infrequently, but the same query runs thousands of times.</p>
<p>Redis looks like an obvious fix.</p>
<pre><code class="language-ts">async function getProduct(id: string) {
  const key = `product:${id}`;

  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const product = await productRepository.findOneBy({ id });

  if (product) {
    await redis.set(key, JSON.stringify(product), {
      EX: 3600,
    });
  }

  return product;
}
</code></pre>
<p>On a cache hit, an expensive database read disappears from the request path.</p>
<p>For example:</p>
<pre><code class="language-text">Database read:  ~800ms
Redis hit:       ~30ms
</code></pre>
<p>Those numbers are illustrative, but the improvement can be substantial when the original operation is expensive.</p>
<p>So far, Redis is doing exactly what we wanted.</p>
<p>The interesting problems start when the underlying data changes.</p>
<h2>The Database Is Correct. The Response Isn't.</h2>
<p>Consider product <code>123</code>.</p>
<p>PostgreSQL contains:</p>
<pre><code class="language-json">{
  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 129
}
</code></pre>
<p>Redis still contains an older copy:</p>
<pre><code class="language-json">{
  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 99
}
</code></pre>
<p>The application checks Redis first:</p>
<pre><code class="language-ts">const cached = await redis.get("product:123");

if (cached) {
  return JSON.parse(cached);
}
</code></pre>
<p>Nothing fails.</p>
<p>The database is healthy. Redis is healthy. The request returns <code>200 OK</code>.</p>
<p>It also returns the wrong price.</p>
<p>The obvious fix is invalidation.</p>
<pre><code class="language-ts">await productRepository.save(product);
await redis.del(`product:${product.id}`);
</code></pre>
<p>For a single cache key, that's straightforward.</p>
<p>Most applications don't have a single cache key.</p>
<h2>One Row Can Exist in Several Cached Responses</h2>
<p>Product <code>123</code> might appear in:</p>
<pre><code class="language-text">product:123
products:list
products:category:keyboards
products:featured
search:mechanical-keyboard
</code></pre>
<p>Updating one database row can make every one of those entries stale.</p>
<p>The write path now needs to know about the read paths:</p>
<pre><code class="language-ts">await productRepository.save(product);

await Promise.all([
  redis.del(`product:${product.id}`),
  redis.del("products:list"),
  redis.del(`products:category:${product.categoryId}`),
  redis.del("products:featured"),
]);
</code></pre>
<p>And that still doesn't necessarily handle search results, filtered lists, pagination, or other cached representations containing the product.</p>
<p>This is where caching starts affecting application design.</p>
<p>Without caching:</p>
<pre><code class="language-text">update product -&gt; database
</code></pre>
<p>With several cached representations:</p>
<pre><code class="language-text">update product
      |
      +--&gt; database
      +--&gt; product cache
      +--&gt; category cache
      +--&gt; list cache
      +--&gt; featured cache
      +--&gt; search cache
</code></pre>
<p>Reads became cheaper, but correctness now depends on invalidating every relevant representation.</p>
<p>The more places the same data is cached, the harder that becomes.</p>
<h2>Redis Failure Is Not the Same as a Cache Miss</h2>
<p>A common cache-aside implementation assumes this:</p>
<pre><code class="language-ts">const cached = await redis.get(key);

if (cached) {
  return JSON.parse(cached);
}

return database.findProduct(id);
</code></pre>
<p>But if Redis is unavailable, <code>redis.get()</code> doesn't necessarily return <code>null</code>.</p>
<p>It may throw.</p>
<p>It may also spend time reconnecting or waiting for a network timeout before failing.</p>
<p>So a real fallback needs to treat Redis as a dependency that can fail.</p>
<pre><code class="language-ts">async function getProduct(id: string) {
  const key = `product:${id}`;

  try {
    const cached = await redis.get(key);

    if (cached) {
      return JSON.parse(cached);
    }
  } catch (error) {
    logger.warn({ error, key }, "Redis read failed");
  }

  const product = await productRepository.findOneBy({ id });

  if (!product) {
    return null;
  }

  try {
    await redis.set(key, JSON.stringify(product), {
      EX: 3600,
    });
  } catch (error) {
    logger.warn({ error, key }, "Redis write failed");
  }

  return product;
}
</code></pre>
<p>This allows the database path to continue when Redis fails.</p>
<p>It doesn't mean the system is safe.</p>
<p>Suppose Redis normally absorbs 90% of product reads.</p>
<pre><code class="language-text">10,000 requests/minute

Redis hits:        9,000
PostgreSQL reads:  1,000
</code></pre>
<p>Now Redis becomes unavailable.</p>
<pre><code class="language-text">10,000 requests/minute

Redis hits:            0
PostgreSQL reads: 10,000
</code></pre>
<p>The database didn't fail.</p>
<p>Its workload changed by an order of magnitude.</p>
<p><img src="https://mermaid.ink/img/pako:eNpV0E1PAkEMBuC_UnuePZh44mDCh6iJJgLqZfHQHbrQ0J2uM7MgGv67YdWoxzZP-r7pB3pbMQ6wVtv7DcUMj5NlAAAYlnNeSYIu0I5EqVJ-gaK4hFE559eOU05QkypU5LeQDR4s5XXkxezu5evCqOfjckKZKkoMOVJdiwcJPjIlTt9w3MNJObYQ2GexAK2ZQi2qP2bSm6ty1nEUTrAnyaAW1hy_xVUvpr_tIjckAazl8F9Oe3ldDttWxVMf6C34LkYO_vCnHzpsODYkKxx8YN5wc3rWimvqNKP72jxTlNN70snUFvKUGtEDDrCgtlUu0iFlbhyMVML2nvyin6cWsoMlLnhtDE-3S3Qwt8qyObhh3XEWTw6GUUgdJAqpSBylRteHLOT91OX8on3D49FhtR6bWsQBnu03khmPn-qVn7A?type=png" alt="Mermaid Diagram" /></p>
<p>This is why testing a Redis outage with one request proves very little.</p>
<p>The interesting test is Redis unavailable <strong>under normal production traffic</strong>.</p>
<p>There is another detail worth handling: the Redis client itself needs sensible connection and command timeouts. A fallback doesn't help much if every request waits several seconds for Redis before reaching PostgreSQL.</p>
<h2>TTLs Can Move Load Instead of Removing It</h2>
<p>Suppose thousands of cache entries are populated during a batch import or deployment.</p>
<p>They all receive the same TTL:</p>
<pre><code class="language-ts">const CACHE_TTL = 3600;

await redis.set(key, value, {
  EX: CACHE_TTL,
});
</code></pre>
<p>If many entries are created around 10:00, many become eligible for expiration around 11:00.</p>
<p>Traffic that was previously hitting Redis starts rebuilding those entries from the database.</p>
<p>Instead of database work being spread across the hour, some of it becomes concentrated around expiration.</p>
<p>A small amount of TTL jitter helps avoid unnecessary synchronization:</p>
<pre><code class="language-ts">function ttlWithJitter(baseSeconds: number) {
  const jitter = Math.floor(Math.random() * 300);

  return baseSeconds + jitter;
}

await redis.set(key, value, {
  EX: ttlWithJitter(3600),
});
</code></pre>
<p>Entries created together no longer have identical expiration times.</p>
<p>Jitter doesn't solve cache stampedes, though.</p>
<h2>One Expired Key Can Trigger Many Queries</h2>
<p>Consider a popular cache entry:</p>
<pre><code class="language-text">homepage:products
</code></pre>
<p>It expires.</p>
<p>Before any request has rebuilt it, 100 requests arrive.</p>
<p>Each one executes:</p>
<pre><code class="language-ts">const cached = await redis.get(key);

if (!cached) {
  return loadFromDatabase();
}
</code></pre>
<p>All 100 requests observe the same miss.</p>
<p><img src="https://mermaid.ink/img/pako:eNpVjz1vwjAQhv_K9WYHiZYpQyW-onboQtouhOGSXBKrjm1sB0gR_70iQaiMz3PvfZ2xMCVjjJUyx6IhF-BzlWkAgPm2MS1bqjm2zpRdETzwyUrHfgdR9AqL7Yb3HfsA092tZfDLu39-8Ku7f3nw67ufTSaTXabH4mIoJtv1ybL28sBQUqCcPMO-Y9ffZizH2Air_7AeAQW27FqSJcZnDA23139LrqhTAcVovslJyhX7a6YyOiTUStVjjBFZqzjyvQ_cClgoqX8-qEgHTowOAjJMuTYMX-8ZCtiY3AQj4I3VgYMsSMDcSVICPGkfeXayQjEsSeXv9ZbpzJ7wchGY10ujjMMYn46NDIyXP4nagqI?type=png" alt="Mermaid Diagram" /></p>
<p>The cache normally prevents repeated work, but during that window it provides no coordination between callers.</p>
<p>This is a cache stampede.</p>
<p>For a single Node.js process, duplicate work can be coalesced with an in-flight promise:</p>
<pre><code class="language-ts">const pending = new Map&lt;string, Promise&lt;unknown&gt;&gt;();

async function loadOnce&lt;T&gt;(
  key: string,
  loader: () =&gt; Promise&lt;T&gt;,
): Promise&lt;T&gt; {
  const existing = pending.get(key);

  if (existing) {
    return existing as Promise&lt;T&gt;;
  }

  const request = loader().finally(() =&gt; {
    pending.delete(key);
  });

  pending.set(key, request);

  return request;
}
</code></pre>
<p>Now concurrent requests inside that process can share one rebuild.</p>
<p>But process-local coordination has an important limitation.</p>
<p>Suppose the application runs four instances:</p>
<pre><code class="language-text">             Load Balancer
          /       |       \
       API-1    API-2    API-3    API-4
         |        |        |        |
         +--------+--------+--------+
                          |
                     PostgreSQL
</code></pre>
<p>Each instance has its own <code>pending</code> map.</p>
<p>The same expired key can therefore trigger one rebuild per instance.</p>
<p>Four instances may produce four expensive queries instead of 100. That's much better, but it isn't globally coordinated.</p>
<p>For expensive or high-traffic keys, options include distributed locking, stale-while-revalidate, background refresh, or other forms of cross-instance request coordination.</p>
<p>Which one makes sense depends on how expensive stale data and duplicate work are for that particular cache.</p>
<h2>Sometimes Redis Is Hiding the Real Problem</h2>
<p>Suppose a database query takes 400ms.</p>
<p>Caching it might reduce most requests to a few milliseconds.</p>
<p>Before adding Redis, run:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT ...
</code></pre>
<p>Maybe the query is scanning 800,000 rows because an index is missing.</p>
<p>After fixing the query:</p>
<pre><code class="language-text">Before index: ~400ms
After index:   ~30ms
</code></pre>
<p>Those numbers are an example, but the point matters: the cache would have hidden a query that should have been fixed.</p>
<p>The same applies to N+1 queries.</p>
<p>Or fetching entire relations when the response needs three fields.</p>
<p>Or repeatedly calling an external service when the data could have been included in the original request.</p>
<p>Caching a slow operation and optimizing a slow operation are different things.</p>
<p>Before deciding to cache an endpoint, it helps to know why the endpoint is expensive.</p>
<h2>Not Everything Expensive Should Be Cached</h2>
<p>A useful cache candidate usually has some combination of:</p>
<ul>
<li><p>expensive computation or I/O;</p>
</li>
<li><p>frequent reads;</p>
</li>
<li><p>relatively infrequent changes;</p>
</li>
<li><p>tolerance for some amount of staleness;</p>
</li>
<li><p>predictable invalidation.</p>
</li>
</ul>
<p>A public category list might fit well.</p>
<pre><code class="language-text">categories:all
</code></pre>
<p>It is requested frequently and probably changes infrequently.</p>
<p>A user dashboard is different.</p>
<pre><code class="language-text">dashboard:user:4821
</code></pre>
<p>It might contain:</p>
<pre><code class="language-text">permissions
subscription state
usage limits
billing information
recent activity
account settings
</code></pre>
<p>Some of those values can change independently, and some may need stronger freshness guarantees than others.</p>
<p>Caching the entire dashboard as one object may save database work, but it also turns several independent data sources into one invalidation problem.</p>
<p>The question isn't only whether caching makes the endpoint faster.</p>
<p>The question is whether the performance gain is worth the new correctness problem.</p>
<h2>Cache Keys Are Part of the Architecture</h2>
<p>Cache keys often start simple:</p>
<pre><code class="language-text">product:123
</code></pre>
<p>Then requirements arrive.</p>
<p>Different currencies:</p>
<pre><code class="language-text">product:123:USD
product:123:EUR
</code></pre>
<p>Different locales:</p>
<pre><code class="language-text">product:123:en
product:123:fr
</code></pre>
<p>Different tenants:</p>
<pre><code class="language-text">tenant:42:product:123
tenant:91:product:123
</code></pre>
<p>Different permissions or response variants can add more dimensions.</p>
<p>At that point, cache-key design isn't an implementation detail.</p>
<p>A missing dimension can return stale data.</p>
<p>A missing tenant identifier can return someone else's data.</p>
<p>A key format that is difficult to invalidate can turn a simple update into a broad cache purge.</p>
<p>The key needs to represent every input that can materially change the cached response.</p>
<p>That is easy to say and surprisingly easy to get wrong.</p>
<h2>What a Safer Cache-Aside Path Looks Like</h2>
<p>A cache-aside read path is still simple conceptually:</p>
<p><img src="https://mermaid.ink/img/pako:eNpVkEFPg0AQhf_KOOflYOKJg6ZQsSbWGBpNDPQwwFA2LizuLq1a-O8GCI0eZ957873MGXNdMPpYKn3KKzIOnuK0AQBYJTF_dmzdHjzvFoJzSHnFQG1rdGskOb4b0mb2BqOlf9Y9hMmaHGVkef9XemfbwzqJuZB2v6TWk7SRrof7JGbXmQbyEVLAkVS3XJhtW2ktaAMlSdUZ7iFczoRTvygJOqkKMGxb3Vzw0SQ-JC-67RQ5ngH_xM3CvkRRYM2mJlmgf0ZXcT2-qOCSOuVQzJs3MpIyxXb0lLpxEdVSfaOPHrWtYs9-W8e1gEDJ5mNL-W6aI904ASnu-KAZXh9TFBDrTDstYMPqyE7mJGBlJCkBlhrrWTayRDFBdvJn7HJ9037hMAjMDqFW2qCPV6dKOsbhF7MsmJA?type=png" alt="Mermaid Diagram" /></p>
<p>The difficult parts are outside that diagram:</p>
<ul>
<li><p>deciding what deserves to be cached;</p>
</li>
<li><p>choosing keys that represent the actual response;</p>
</li>
<li><p>invalidating every affected representation;</p>
</li>
<li><p>preventing synchronized expiration;</p>
</li>
<li><p>controlling expensive rebuilds;</p>
</li>
<li><p>handling Redis latency and failure;</p>
</li>
<li><p>ensuring the database can survive reduced cache effectiveness.</p>
</li>
</ul>
<p>Redis solves none of those automatically.</p>
<h2>Redis Wasn't the Problem</h2>
<p>Redis can remove an enormous amount of repeated work from a system.</p>
<p>It can also make a poorly understood performance problem harder to see.</p>
<p>If an endpoint is slow, start with the endpoint.</p>
<p>Measure the query.</p>
<p>Look at the query plan.</p>
<p>Check how much data is being fetched.</p>
<p>Check downstream calls.</p>
<p>Look for repeated computation.</p>
<p>Then decide whether the remaining work is something worth caching.</p>
<p>The better question isn't:</p>
<blockquote>
<p>Should this use Redis?</p>
</blockquote>
<p>It's:</p>
<blockquote>
<p>What work are we avoiding, how long can we reuse its result, and what happens when that result is stale or unavailable?</p>
</blockquote>
<p>If those answers are clear, Redis can be remarkably effective.</p>
<p>If they aren't, a 30ms cache hit may simply be hiding the next production problem.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/we-added-redis-to-make-things-faster-it-made-things-worse-4ganu?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[IDOR Vulnerabilities in NestJS: How to Build Ownership Guards That Actually Protect Your Data]]></title><description><![CDATA[1. The Gap Between Authentication and Authorization
Here is a NestJS controller that most developers would look at and consider secure:
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class In]]></description><link>https://blog.zyvop.com/idor-vulnerabilities-in-nestjs-how-to-build-ownership-guards-that-actually-protect-your-data</link><guid isPermaLink="true">https://blog.zyvop.com/idor-vulnerabilities-in-nestjs-how-to-build-ownership-guards-that-actually-protect-your-data</guid><category><![CDATA[authorization]]></category><category><![CDATA[BackendSecurity]]></category><category><![CDATA[bola]]></category><category><![CDATA[IDOR]]></category><category><![CDATA[nestjs]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:53:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/873e853e-a00b-4c53-b858-18197c904bac.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>1. The Gap Between Authentication and Authorization</h2>
<p>Here is a NestJS controller that most developers would look at and consider secure:</p>
<pre><code class="language-typescript">@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
  constructor(private readonly invoicesService: InvoicesService) {}

  @Get(':id')
  getInvoice(@Param('id') id: string) {
    return this.invoicesService.findOne(id);
  }
}
</code></pre>
<pre><code class="language-typescript">async findOne(id: string): Promise&lt;Invoice&gt; {
  const invoice = await this.repo.findOneBy({ id });
  if (!invoice) throw new NotFoundException();
  return invoice;
}
</code></pre>
<p>The JWT guard works. An anonymous attacker gets a 401. An authenticated attacker with a free-tier account gets whatever they ask for:</p>
<pre><code class="language-bash">GET /invoices/INV-10022  →  200 OK  { userId: 99, amount: 14990 }
GET /invoices/INV-10023  →  200 OK  { userId: 156, amount: 3200 }
# Every invoice in the database, iterated with a for loop
</code></pre>
<p>The vulnerability is not the identifier. It is the missing check that the identifier belongs to the user making the request.</p>
<p>This is <strong>IDOR</strong> (Insecure Direct Object Reference) — also called <strong>BOLA</strong> (Broken Object Level Authorization) in API security contexts. It falls under A01:2025 in the OWASP Top 10 and API1:2023 in the OWASP API Security list. It is consistently one of the most common causes of API data breaches in SaaS applications — not because it is sophisticated, but because it is a logic bug that automated tools do not catch.</p>
<p>Authentication and authorization answer different questions:</p>
<p><img src="https://mermaid.ink/img/pako:eNp9UltvEkEU_ivHUxMTs1AufSmJGi4LRQsoS9vITmOG3bPshGGGzAxU2pD4ok8--OCj_jl-idnlUk0a521OvvPdch4w0jFhDROp76KUGweXQ6YAAIb-h5Bhxx_BqVArLSKyp93-daFcKlUqjKkGcUMGaP02nXSKxSLDWygUXkP9anTRZ2pHYpeTqeGLdDcNGdaXLiXlRMSd0Aq2v74yvN1hs1cPGXYtuFRYcHpGClZcivjNgbwRMvxIFrZffsLSkoGzynGfVPyk7Hgvq42436v-_g69bhB0-51_1Jshw5Yme6CGwU0fjpmPJlohwz6tyECUUjSj-CkLeeBjIePH4XjHkSUJ_Eu_OYKX0B4OenBoGW4u_KEPIoZX8LzMmNp--wFK56Y-iRgSIR2Zo2arkRMO_eB9yLBSKsHg3WM_5-cvLMTc8Ry_b8etJe29JELK2kmSUJWqnnVGz6h2QqVquVr-G5yxw__A6OGczJyLGGsP6FKaZ1cVU8KX0qG3m1xzI_hEks0wiVauzedCrrGGBb5YSCrYtXU096AhhZr1eBTk_7ZWzgOGAU01wVWXoQdDPdFOe3BBckXZOXlQN4JLDyxXtmDJiAS9XCQQ95mX8tniM242Hk6mTS21wRo-u0uFI9z8AWva8zY?type=png" alt="Mermaid Diagram" /></p>
<p>Nearly every NestJS tutorial covers authentication in depth. Object-level authorization is almost never discussed. That is the gap.</p>
<hr />
<h2>2. Five Places IDOR Hides in NestJS</h2>
<p>URL parameters are the obvious case. These five are the ones that slip through even after a team has been briefed.</p>
<p><strong>1. Request body IDs — trusting user-supplied ownership</strong></p>
<pre><code class="language-typescript">// VULNERABLE: userId comes from the client
@Put('profile')
@UseGuards(JwtAuthGuard)
async updateProfile(@Body() dto: UpdateProfileDto) {
  return this.service.update(dto.userId, dto); // attacker sends userId: 999
}
</code></pre>
<p><strong>2. Nested resource parents not validated</strong></p>
<pre><code class="language-typescript">// VULNERABLE: checks the comment exists, not that postId belongs to the user
@Get('posts/:postId/comments/:commentId')
@UseGuards(JwtAuthGuard)
async getComment(@Param('postId') postId: string, @Param('commentId') commentId: string) {
  return this.commentsService.findOne(postId, commentId);
}
</code></pre>
<p><strong>3. Bulk and batch operations with mixed IDs</strong></p>
<pre><code class="language-typescript">// VULNERABLE: processes any IDs sent, including other users' records
@Delete('invoices/bulk')
@UseGuards(JwtAuthGuard)
async bulkDelete(@Body() dto: BulkDeleteDto) {
  return this.service.deleteMany(dto.ids); // no ownership filter on the array
}
</code></pre>
<p><strong>4. Export and download endpoints</strong></p>
<pre><code class="language-typescript">// VULNERABLE: export endpoints receive heavy feature testing, light security testing
@Get('reports/:reportId/export')
@UseGuards(JwtAuthGuard)
async exportReport(@Param('reportId') id: string, @Res() res: Response) {
  const pdf = await this.reportsService.generatePdf(id);
  res.send(pdf); // streams any report to any authenticated user
}
</code></pre>
<p><strong>5. Pagination and list endpoints without user scope</strong></p>
<pre><code class="language-typescript">// VULNERABLE: returns all records from the table, paginated
@Get('invoices')
@UseGuards(JwtAuthGuard)
async listInvoices(@Query() page: PaginationDto) {
  return this.repo.findAndCount({ take: page.limit, skip: page.offset });
  // ← no WHERE userId filter — attacker pages through everything
}
</code></pre>
<p>The list endpoint case is frequently missed because it looks like a collection route, not a resource lookup. Without a <code>userId</code> scope, a single authenticated user can page through the entire table.</p>
<hr />
<h2>3. Fix 1: Block Mass Assignment at the DTO Layer</h2>
<p>TypeScript types disappear at runtime. A <code>UpdateInvoiceDto</code> that doesn't declare a <code>userId</code> field still accepts one from the client at the wire level unless you explicitly block it.</p>
<p><strong>Enable whitelist validation globally:</strong></p>
<pre><code class="language-typescript">// main.ts
app.useGlobalPipes(new ValidationPipe({
  whitelist:            true,   // strip fields not in the DTO
  forbidNonWhitelisted: true,   // reject requests that include extra fields
  transform:            true,
}));
</code></pre>
<p><strong>Never include ownership fields in write DTOs:</strong></p>
<pre><code class="language-typescript">// CORRECT — userId is absent. It comes from the JWT, never from the client.
export class CreateInvoiceDto {
  @IsString() @IsNotEmpty() description: string;
  @IsNumber() @IsPositive()  amount:      number;
  @IsEnum(Currency)          currency:    Currency;
}

export class UpdateInvoiceDto extends PartialType(CreateInvoiceDto) {
  // inherits the same safe fields — still no userId, orgId, or role
}
</code></pre>
<p><strong>Always inject userId from the verified token:</strong></p>
<pre><code class="language-typescript">@Post()
@UseGuards(JwtAuthGuard)
createInvoice(
  @Body() dto: CreateInvoiceDto,
  @CurrentUser() user: JwtPayload,   // from JWT signature — not client input
) {
  return this.service.create({ ...dto, userId: user.id });
}
</code></pre>
<p>The <code>@CurrentUser()</code> decorator reads from <code>req.user</code>, which is populated by <code>JwtAuthGuard</code> from the verified token — never from anything the client controls:</p>
<pre><code class="language-typescript">// decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): JwtPayload =&gt;
    ctx.switchToHttp().getRequest().user,
);
</code></pre>
<blockquote>
<p><strong>Why</strong> <code>forbidNonWhitelisted</code> <strong>over</strong> <code>whitelist</code> <strong>alone?</strong> Silent stripping makes the API behavior confusing and harder to audit. Rejecting with a 400 makes the contract explicit and logs the attempted overpost.</p>
</blockquote>
<hr />
<h2>4. Fix 2: Scope Every PostgreSQL Query With User Context</h2>
<p>This is the most important fix. If your repository methods are built correctly, IDOR at the query level becomes structurally impossible: a wrong <code>userId</code> returns nothing.</p>
<pre><code class="language-typescript">// invoices.repository.ts
@Injectable()
export class InvoicesRepository {
  constructor(private readonly prisma: PrismaService) {}

  findOneByOwner(id: string, userId: string) {
    return this.prisma.invoice.findFirst({
      where: { id, userId },   // wrong userId → null, never another user's row
    });
  }

  findAllByOwner(userId: string, page: PaginationDto) {
    return this.prisma.invoice.findMany({
      where:   { userId },     // scoped — never returns other users' records
      take:    page.limit,
      skip:    page.offset,
      orderBy: { createdAt: 'desc' },
    });
  }

  async updateByOwner(id: string, userId: string, data: Partial&lt;Invoice&gt;) {
    const { count } = await this.prisma.invoice.updateMany({
      where: { id, userId },   // wrong userId → count 0, no mutation
      data,
    });
    return count &gt; 0 ? this.findOneByOwner(id, userId) : null;
  }

  async deleteByOwner(id: string, userId: string): Promise&lt;boolean&gt; {
    const { count } = await this.prisma.invoice.deleteMany({
      where: { id, userId },   // wrong userId → count 0, no deletion
    });
    return count &gt; 0;
  }
}
</code></pre>
<pre><code class="language-typescript">// invoices.service.ts
async findOne(id: string, userId: string): Promise&lt;Invoice&gt; {
  const invoice = await this.repo.findOneByOwner(id, userId);
  if (!invoice) throw new NotFoundException('Invoice not found');
  // ↑ 404 not 403 — do not reveal that the resource exists for a different user
  return invoice;
}
</code></pre>
<pre><code class="language-typescript">// invoices.controller.ts
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
  @Get()
  list(@CurrentUser() user: JwtPayload, @Query() page: PaginationDto) {
    return this.service.findAll(user.id, page);
  }

  @Get(':id')
  get(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
    return this.service.findOne(id, user.id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() dto: UpdateInvoiceDto, @CurrentUser() user: JwtPayload) {
    return this.service.update(id, user.id, dto);
  }

  @Delete(':id') @HttpCode(204)
  delete(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
    return this.service.delete(id, user.id);
  }
}
</code></pre>
<p>The rule is simple: <code>userId</code> flows down from the JWT payload. It is never accepted from a route parameter, query string, or request body.</p>
<hr />
<h2>5. Fix 3: The Ownership Guard Pattern</h2>
<p>Repository-scoped queries protect against IDOR at the database layer. But service methods get called from background jobs, admin scripts, and other services that bypass controllers. A guard enforces ownership at the HTTP boundary independently — catching what the service layer might miss if called from a non-HTTP context.</p>
<pre><code class="language-typescript">// guards/ownership.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  NotFoundException,
  SetMetadata,
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';

export interface OwnershipConfig {
  service:    string;   // Injectable token (e.g. InvoicesService.name)
  method:     string;   // Method that fetches the resource (unscoped)
  paramName:  string;   // Route param key holding the resource ID
  ownerField: string;   // Field on the resource that holds the owner's user ID
}

export const OWNERSHIP_KEY = 'ownership';
export const CheckOwnership = (config: OwnershipConfig) =&gt;
  SetMetadata(OWNERSHIP_KEY, config);

@Injectable()
export class OwnershipGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly moduleRef:  ModuleRef,
  ) {}

  async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
    const config = this.reflector.getAllAndOverride&lt;OwnershipConfig&gt;(
      OWNERSHIP_KEY,
      [context.getHandler(), context.getClass()],
    );

    if (!config) return true;

    const request  = context.switchToHttp().getRequest();
    const user     = request.user as JwtPayload;
    const id       = request.params[config.paramName];

    const service  = this.moduleRef.get(config.service, { strict: false });
    const resource = await service[config.method](id);

    // No resource, or resource belongs to a different user → same response: 404
    // Never return 403 here — it confirms the resource exists, aiding enumeration
    if (!resource || String(resource[config.ownerField]) !== String(user.id)) {
      throw new NotFoundException();
    }

    request.resource = resource;  // pre-fetched — avoids a second DB call in the handler
    return true;
  }
}
</code></pre>
<p>Apply it per route:</p>
<pre><code class="language-typescript">@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {

  @Get(':id')
  @UseGuards(OwnershipGuard)
  @CheckOwnership({
    service:    InvoicesService.name,
    method:     'findOneUnsafe',   // unscoped fetch — guard handles ownership
    paramName:  'id',
    ownerField: 'userId',
  })
  getInvoice(@Request() req) {
    return req.resource;   // already fetched and validated — no second query
  }
}
</code></pre>
<p>The two layers do different jobs:</p>
<p><img src="https://mermaid.ink/img/pako:eNqNktFuGjEQRX9lOn1d2pBQpCKlUkKWhkQFlU3ZB5wH451lLYxnY3shJCD1I_qF_ZJqISFt1Ep9tOdc33vleUTFGWEHc8MrVUgX4OZCWACAUfx1IrBvFS-0ncGI7iryQeAtNBqf4Cq92WNX6c1E4NUqnFWh-FxJlwlhx-R0rslD4DnZJ82mb5fS6GwDcXMisHXUFHh7eGRHjPfzYToQdj8ZpoOJwOHKkvOFLp8NehRUQR4cea6cIiFstyA198A12tNkMjg9PYXKk3uns4PTMB3snAQOOEDOlc2AHawc29leK3AD8fEuYOsvsi47Ryq8sMm4-xw2GXcnAhNyS60I3sOISvY6sFsLYRPFJWVwV5FbQ3oZj2LQGZwNLnYZ-3Wrc6nmPnAJOodZ3RS0h-m6lN7TS4Vk3D1UAMcrcBQqZ2tiA_HJq-Qv9OgVOryeCDw-OoKf33-ArELBTj88-ey1PqwNQdyEXBvTeZvn7Wl7Gik27OpT_gd1_F_UyYGSH1vZP6jhNTxhH5oqb7d_wzDCBbmF1Bl2HjEUtKi3N6NcViZgtL8ZS6fl1JCvmZxt6MmFNmvsYEOWpaGGX_tAiwjOjbbzL1Ilu3OPbYig_sAZE3zrC4xgxFMOHMElmSUFrWQEZ05LE4GX1jd8vegY7UwS_VBnabbKe9xuI5zOunVs7OCbVaED4fYXlBgfiw?type=png" alt="Mermaid Diagram" /></p>
<p>The guard catches IDOR at the HTTP boundary. The scoped query is the backstop for service methods called outside the HTTP lifecycle.</p>
<blockquote>
<p><strong>Complex authorization needs?</strong> For team memberships, org hierarchies, or status-conditional rules (e.g., "only update if not finalized"), <a href="https://casl.js.org">CASL</a> integrates cleanly with NestJS and Prisma. Build the scoped query layer first — CASL adds expressiveness on top, not a replacement for it.</p>
</blockquote>
<hr />
<h2>6. Fix 4: PostgreSQL Row-Level Security as a Backstop</h2>
<p>All of the above is application-layer defense. RLS enforces ownership at the database itself — even a completely unguarded endpoint returns an empty result set for rows that don't belong to the session's current user.</p>
<pre><code class="language-sql">-- Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Policy: app_user_role can only see rows where user_id matches the session variable
CREATE POLICY invoices_owner_policy ON invoices
  AS PERMISSIVE FOR ALL
  TO app_user_role
  USING (user_id = current_setting('app.current_user_id', true)::uuid);
-- The 'true' flag means missing setting returns NULL (row hidden) rather than an error
</code></pre>
<p>Set the session variable per request, inside a transaction so <code>SET LOCAL</code> scopes correctly:</p>
<pre><code class="language-typescript">// rls.interceptor.ts — runs after JwtAuthGuard populates req.user
@Injectable()
export class RlsInterceptor implements NestInterceptor {
  constructor(@InjectDataSource() private ds: DataSource) {}

  async intercept(ctx: ExecutionContext, next: CallHandler) {
    const user = ctx.switchToHttp().getRequest().user as JwtPayload | undefined;
    if (user?.id) {
      await this.ds.query('SET LOCAL app.current_user_id = $1', [user.id]);
      // SET LOCAL is transaction-scoped; register this interceptor after
      // your transaction middleware if you use one
    }
    return next.handle();
  }
}
</code></pre>
<p>RLS is the deepest layer of defense — not a replacement for the application layer, but a guarantee that a missed guard cannot silently leak data.</p>
<hr />
<h2>7. UUIDs vs Sequential IDs</h2>
<p>Use UUID primary keys. Sequential IDs (<code>1, 2, 3</code>) make enumeration trivial and expose record counts and creation velocity. UUIDs (v4) make blind guessing statistically impossible.</p>
<p>This is defense-in-depth, not a primary fix. An attacker who obtains a UUID through a shared link, referrer header, or log exposure can still exploit IDOR if ownership checks are absent. Obfuscating the identifier is not a substitute for verifying ownership.</p>
<pre><code class="language-typescript">// Prisma
model Invoice {
  id     String @id @default(uuid())
  userId String
}

// TypeORM
@Entity()
export class Invoice {
  @PrimaryGeneratedColumn('uuid') id: string;
  @Column()                       userId: string;
}
</code></pre>
<p>Use UUIDs. Add ownership checks. Both.</p>
<hr />
<h2>8. Testing IDOR: The Two-User Pattern</h2>
<p>Catching IDOR requires at least two authenticated sessions: one to own the resource, one to attempt unauthorized access. Most automated scanners run a single session and miss this entirely.</p>
<pre><code class="language-typescript">// test/security/invoices.idor.spec.ts
describe('IDOR — Invoices', () =&gt; {
  let app:        INestApplication;
  let aliceToken: string;
  let bobToken:   string;
  let aliceInvoiceId: string;

  beforeAll(async () =&gt; {
    const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true, forbidNonWhitelisted: true, transform: true,
    }));
    await app.init();

    // Create two independent users
    await request(app.getHttpServer()).post('/auth/register')
      .send({ email: 'alice@test.com', password: 'AlicePass1!' });
    await request(app.getHttpServer()).post('/auth/register')
      .send({ email: 'bob@test.com', password: 'BobPass1!' });

    aliceToken = (await request(app.getHttpServer()).post('/auth/login')
      .send({ email: 'alice@test.com', password: 'AlicePass1!' })).body.access_token;
    bobToken = (await request(app.getHttpServer()).post('/auth/login')
      .send({ email: 'bob@test.com', password: 'BobPass1!' })).body.access_token;

    // Alice creates a resource
    aliceInvoiceId = (await request(app.getHttpServer())
      .post('/invoices')
      .set('Authorization', `Bearer ${aliceToken}`)
      .send({ description: 'Alice Invoice', amount: 499, currency: 'USD' })).body.id;
  });

  afterAll(() =&gt; app.close());

  it('Alice can read her own invoice', () =&gt;
    request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${aliceToken}`)
      .expect(200));

  it('Bob CANNOT read Alice\'s invoice', () =&gt;
    request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${bobToken}`)
      .expect(404));   // Must be 404 — never 200 or 403

  it('Bob CANNOT update Alice\'s invoice', async () =&gt; {
    await request(app.getHttpServer())
      .patch(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${bobToken}`)
      .send({ description: 'Tampered' })
      .expect(404);

    // Verify the invoice was not modified
    const res = await request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${aliceToken}`)
      .expect(200);
    expect(res.body.description).toBe('Alice Invoice');
  });

  it('Bob CANNOT delete Alice\'s invoice', async () =&gt; {
    await request(app.getHttpServer())
      .delete(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${bobToken}`)
      .expect(404);

    // Alice's invoice must still exist
    await request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${aliceToken}`)
      .expect(200);
  });

  it('Bulk delete cannot include IDs from other users', async () =&gt; {
    await request(app.getHttpServer())
      .delete('/invoices/bulk')
      .set('Authorization', `Bearer ${bobToken}`)
      .send({ ids: [aliceInvoiceId] });

    // Alice's invoice must be untouched regardless of status code
    await request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${aliceToken}`)
      .expect(200);
  });

  it('Bob\'s invoice list does not contain Alice\'s invoices', async () =&gt; {
    const res = await request(app.getHttpServer())
      .get('/invoices')
      .set('Authorization', `Bearer ${bobToken}`)
      .expect(200);

    const ids = res.body.data?.map((i: { id: string }) =&gt; i.id) ?? [];
    expect(ids).not.toContain(aliceInvoiceId);
  });

  it('Mass assignment attempt is rejected', () =&gt;
    request(app.getHttpServer())
      .patch(`/invoices/${aliceInvoiceId}`)
      .set('Authorization', `Bearer ${bobToken}`)
      .send({ userId: 'new-owner-id' })
      .expect(400));   // forbidNonWhitelisted rejects unrecognised fields

  it('Unauthenticated request is rejected', () =&gt;
    request(app.getHttpServer())
      .get(`/invoices/${aliceInvoiceId}`)
      .expect(401));
});
</code></pre>
<p>Add this test file to every resource module. The pattern is always: User A creates → User B attempts every verb → assert 404 → verify resource is unchanged.</p>
<hr />
<h2>9. Detecting Active Enumeration</h2>
<p>Even with all fixes in place, monitor for active probing. The signature is distinctive: a single authenticated user generating a high 404 rate on resource endpoints.</p>
<pre><code class="language-typescript">// middleware/idor-detection.middleware.ts
@Injectable()
export class IdorDetectionMiddleware implements NestMiddleware {
  private readonly logger = new Logger('ThreatDetection');
  private readonly RESOURCE_PATH = /^\/(invoices|orders|reports|files)\//;

  constructor(@InjectRedis() private readonly redis: Redis) {}

  use(req: any, res: any, next: Function): void {
    next();
    res.on('finish', async () =&gt; {
      try {
        if (res.statusCode !== 404 || !req.user?.id || !this.RESOURCE_PATH.test(req.path)) return;

        const key = `idor:404:${req.user.id}`;

        // Pipeline makes incr + expire atomic — avoids the race condition
        // of a separate incr() call followed by a separate expire() call
        const [[, count]] = await this.redis.pipeline()
          .incr(key)
          .expire(key, 60)
          .exec() as [[null, number]];

        if (count &gt;= 10) {
          this.logger.warn({
            event:    'POTENTIAL_IDOR_ENUMERATION',
            userId:   req.user.id,
            ip:       req.ip,
            path:     req.path,
            count404: count,
            window:   '60s',
          });
        }
      } catch { /* never let monitoring break request handling */ }
    });
  }
}
</code></pre>
<p>Wire this up globally and route the structured log to your observability platform. A single user generating 10+ 404s per minute on resource endpoints warrants investigation.</p>
<hr />
<h2>10. Checklist</h2>
<p>Before shipping any endpoint that touches user-owned data:</p>
<pre><code>DTO Layer
☐ ValidationPipe: whitelist: true, forbidNonWhitelisted: true, globally
☐ No write DTO (Create/Update) contains userId, orgId, or role fields
☐ Response DTOs use @Exclude() on internal fields (userId, deletedAt)

Controller Layer
☐ Every resource endpoint has @UseGuards(JwtAuthGuard)
☐ userId always comes from @CurrentUser() — never from @Body() or @Param()
☐ Resource-specific routes (GET/PATCH/DELETE /:id) have @UseGuards(OwnershipGuard)

Repository / Service Layer
☐ Every findOne / update / delete takes userId as a required parameter
☐ userId is in every WHERE clause — never optional
☐ List/pagination endpoints scoped by userId — never unfiltered
☐ Bulk operations filter input IDs against the requesting user's owned IDs
☐ Soft-delete queries include userId even when withDeleted: true

Testing
☐ Every resource module has an IDOR test: User A creates → User B attempts all verbs
☐ Bulk endpoint test: User B's request cannot affect User A's records
☐ List endpoint test: User B's response never contains User A's resource IDs
☐ IDOR tests run in CI on every PR
</code></pre>
<hr />
<h2>11. FAQ</h2>
<p><strong>Q: Should I return 403 or 404 when ownership fails?</strong> Return 404. A 403 tells the attacker the resource exists, confirming the ID is valid and encouraging further enumeration. A 404 reveals nothing — the resource either doesn't exist or doesn't belong to the requesting user. Apply this consistently: an inconsistent response pattern across endpoints becomes an oracle.</p>
<p><strong>Q: Is IDOR only a reading problem?</strong> No — writes are often more damaging and harder to detect. Blind IDOR writes don't return the victim's data, so they look less obviously wrong, but they can silently overwrite records, cancel orders, or delete content. Test every HTTP verb (GET, PATCH, PUT, DELETE) in your two-user suite, not just GET.</p>
<p><strong>Q: Does</strong> <code>@Roles()</code> <strong>or any role guard solve IDOR?</strong> No. Role guards answer "does this user have the admin role?" They don't answer "does this user own this specific row?" IDOR is a horizontal authorization problem — two users with the same role accessing each other's data. Role guards are vertical access control. You need both.</p>
<p><strong>Q: How do I handle resources that belong to a team, not a single user?</strong> Extend both layers. At the repository layer: <code>WHERE user_id = \(1 OR team_id = ANY(\)2)</code>. At the guard layer: check <code>user.teamIds.includes(resource.teamId)</code> alongside the <code>userId</code> check. <a href="https://casl.js.org">CASL</a> with <code>@casl/prisma</code> can generate these compound WHERE clauses automatically from your defined ability rules.</p>
<hr />
<h2>12. Further Reading</h2>
<p><strong>OWASP</strong></p>
<ul>
<li><p>📖 <a href="https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/">OWASP Top 10 A01:2025 — Broken Access Control</a></p>
</li>
<li><p>📖 <a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API Security — API1:2023 BOLA</a></p>
</li>
<li><p>📖 <a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html">OWASP Authorization Cheat Sheet</a></p>
</li>
<li><p>📖 <a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639: Authorization Bypass Through User-Controlled Key</a></p>
</li>
</ul>
<p><strong>Deep Dives</strong></p>
<ul>
<li><p>📰 <a href="https://www.codeant.ai/blogs/idor-vulnerabilities">IDOR Vulnerabilities: The Complete Technical Guide (2026) — CodeAnt</a></p>
</li>
<li><p>📰 <a href="https://medium.com/@Modexa/dtos-that-dont-spill-secure-nestjs-validation-patterns-2493e45392d2">DTOs That Don't Spill: Secure NestJS Validation Patterns — Modexa</a></p>
</li>
<li><p>📰 <a href="https://apiiro.com/blog/why-dast-tools-miss-real-idor-vulnerabilities-and-how-ai-helps/">Why DAST Tools Miss Real IDOR Vulnerabilities — Apiiro</a></p>
</li>
</ul>
<p><strong>Reference</strong></p>
<ul>
<li><p>📖 <a href="https://docs.nestjs.com/guards">NestJS Guards</a></p>
</li>
<li><p>📖 <a href="https://docs.nestjs.com/security/authorization">NestJS Authorization</a></p>
</li>
<li><p>📖 <a href="https://casl.js.org/v6/en/package/casl-angular">CASL + NestJS</a></p>
</li>
<li><p>📖 <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL Row-Level Security</a></p>
</li>
</ul>
<hr />
<p><em>Run the two-user test against every existing endpoint before the next release — not just new ones. IDOR is most commonly found in code that was already live.</em></p>
<hr />
<p><em>Published via <a href="https://zyvop.com/idor-vulnerabilities-in-nestjs-how-to-build-ownership-guards-that-actually-protect-your-data-uywfk?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Is AI Actually Cheap Enough to Replace Developers?]]></title><description><![CDATA[Claude Opus 4.8 clears 88.6% on SWE-bench Verified. That's the number everyone quotes to argue AI has basically solved software engineering.
Now drop it into SWE-bench Pro — the version built from pri]]></description><link>https://blog.zyvop.com/is-ai-actually-cheap-enough-to-replace-developers</link><guid isPermaLink="true">https://blog.zyvop.com/is-ai-actually-cheap-enough-to-replace-developers</guid><category><![CDATA[AICodingTools]]></category><category><![CDATA[#AIvsDevelopers]]></category><category><![CDATA[ClaudeCode]]></category><category><![CDATA[#DeveloperProductivity]]></category><category><![CDATA[GitHubCopilot ]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:53:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/eb3b28e8-7a2b-4f6f-b188-ca134f60a96f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Claude Opus 4.8 clears 88.6% on <a href="https://www.vals.ai/benchmarks/swebench">SWE-bench Verified</a>. That's the number everyone quotes to argue AI has basically solved software engineering.</p>
<p>Now drop it into <a href="https://www.morphllm.com/swe-bench-pro">SWE-bench Pro</a> — the version built from private codebases the model has never seen. The best standardized score on the public leaderboard is 59.1%. On the truly private commercial set, where nobody gets to tune in advance, no model clears 47%.</p>
<p>That's not a rounding error. That's a 20-to-40-point cliff, and it shows up everywhere. Gemini 3.1 Pro falls from 80.6% to somewhere between 32% and 46%, depending on which cut you trust. Every lab's flagship shows the same shape of fall.</p>
<p>Here's a wrinkle nobody saw coming: the two highest-scoring models that exist right now — Claude Mythos 5 (95.5%) and Claude Fable 5 (95.0%) — aren't something you can actually use. Anthropic suspended public access to both under an export control directive. The "best" model on paper this week is one you can't buy.</p>
<p>That gap, between the benchmark you can quote and the codebase you actually own, is the entire "AI replaces developers" argument in miniature. Models are extraordinary at problems that resemble what they've already seen. They get shaky fast the moment the code is genuinely <em>yours</em> — your weird conventions, your one engineer's undocumented caching hack from three years ago.</p>
<p>Cost was never the obstacle. Reliability was.</p>
<p>But cost is the question people actually ask. So let's answer it — before getting into why the answer doesn't settle anything.</p>
<h2>The subscription math, updated for June 2026</h2>
<p>Pricing in this category moves fast enough that anything written before May is already stale. And the biggest shift just happened: GitHub Copilot blew up its entire pricing model on June 1, 2026, moving from flat-rate to usage-based credits.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Entry tier</th>
<th>Heavy-agent reality</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub Copilot Pro</td>
<td>\(10/mo + \)15/mo in AI credits</td>
<td>Pro+ (\(39/mo) includes \)70 in credits; Max includes $200</td>
</tr>
<tr>
<td>Cursor Pro</td>
<td>$20/mo, credits = plan price</td>
<td>$60–200/mo on Pro+/Ultra once you pick premium models manually</td>
</tr>
<tr>
<td>Claude Code (Pro)</td>
<td>$20/mo, included usage</td>
<td>$100–200/mo on Max 5x/20x</td>
</tr>
<tr>
<td>OpenAI Codex (via ChatGPT)</td>
<td>included in Plus/Pro/Business</td>
<td>token-credit billing since April 2026; $100–200/mo heavy use</td>
</tr>
</tbody></table>
<p>The fallout from the Copilot switch has been loud. Developers who used to burn 3% of their monthly allowance on a normal day are now burning that much in under an hour. One person reported a single file review — no code written — eating a fifth of their monthly cap.</p>
<p>The flat $10–20 sticker price that defined this market for two years is functionally gone the moment you're doing real agentic work.</p>
<p>A solo developer paying for tokens directly, rather than a subscription, can still land around \(5–\)30 a month for light use. But push an agent into a hard debugging session and a single sitting can burn 500,000+ tokens and \(15 or more. Real heavy-user spend across every tool here now sits at \)100–$200/month — not the number on the landing page.</p>
<p>Scale that to a ten-person team running agents seriously, and you're at \(1,000–\)2,000+/month in tooling alone. That's before anyone counts the human time spent reviewing what the agent produced.</p>
<h2>The other side of the ledger</h2>
<p>Here's the part most "AI is basically free" posts skip: the loaded cost of a developer isn't the number on the offer letter.</p>
<p>Add 25–30% for payroll tax and benefits. Add 15–25% more for equity at the senior tier. A \(150,000 salary becomes \)220,000–$280,000 in year one once recruiting and onboarding get folded in.</p>
<p>Set against that, even Claude Code Max or Cursor Ultra at $2,400/year is rounding error. On subscription-cost-versus-salary-cost alone, AI tooling wins by two orders of magnitude. Every time. Nobody serious disputes that.</p>
<p>The actual argument is whether the AI is doing the job — or just doing the typing while a human still does the job around it.</p>
<h2>The productivity number nobody wanted to publish — and the sequel nobody expected</h2>
<p>In July 2025, METR ran the most rigorous test of this question anyone had attempted. Sixteen experienced open-source developers. 246 real tasks. Randomized into AI-allowed and AI-disallowed groups, working in codebases they'd maintained for years.</p>
<p>The expected result was a speed-up. What they found instead: developers using AI took 19% <em>longer</em>.</p>
<p>Stranger still — those same developers believed they'd been 20% faster. They were wrong about the direction of their own productivity.</p>
<p>METR re-ran it. Bigger cohort, less self-selected: 57 developers, 143 repos, 800+ tasks. Published February 2026. The topline slowdown shrank to roughly -4%. And among the original developers who did both rounds, the number flipped entirely — an 18% speedup, though the confidence interval was wide enough that METR called it weak evidence, not proof.</p>
<p>Then the story took a turn nobody saw coming.</p>
<p>By April 2026, METR scrapped the whole experimental design. The reason: AI use had become so universal that 30–50% of invited developers simply refused to participate without AI access. You can't run a control group when the population won't be controlled.</p>
<p>So METR pivoted. Their May 2026 replacement is a self-report survey of 349 technical workers. Headline number: a median 1.4–2x self-reported increase in the value of their work, with respondents forecasting 2.5x by 2027.</p>
<p>Here's the twist. The one subgroup best positioned to judge this rigorously — METR's own researchers — reported the <em>lowest</em> gains of anyone surveyed. The agency that proved developers overestimate their own speedup just published a 2026 headline number built entirely on self-report, and told readers to be skeptical of it themselves.</p>
<p>What never changed, across every version of this research: the most experienced developers, on the most mature codebases, got the least benefit — sometimes a negative one. One Chrome engineer put it simply: it wasn't about being unfamiliar with the tools. It was about working in a codebase he already knew cold, versus a small greenfield project where a less experienced developer would benefit far more.</p>
<p>Same shape as the SWE-bench Pro cliff. Just measured with a stopwatch instead of a leaderboard.</p>
<h2>A cautionary tale from outside engineering — now with a sequel of its own</h2>
<p>Software isn't the only function that ran this experiment.</p>
<p>In 2023, Klarna replaced roughly 700 customer service staff with an OpenAI-built assistant. For a while, the numbers looked unambiguous: the bot handled two-thirds of all queries. By 2025, the company was walking it back. CEO Sebastian Siemiatkowski's own words: cost became too dominant a factor, and quality dropped in a way that wasn't sustainable.</p>
<p>The 2026 version of that story isn't a clean "AI failed, humans win" ending. Klarna's current setup is a hybrid: AI still handles roughly two-thirds of inquiries, but instead of rehiring full-time staff for the rest, the company routes the remainder to gig-style contractors — brought on flexibly, without the overhead of full employment.</p>
<p>It's a third option nobody was describing in 2023 or 2025: AI for volume, on-demand humans for what AI can't close, and full-time headcount for neither end.</p>
<p>Customer service and software engineering aren't the same job. But the failure mode is identical: optimize a replace-the-headcount decision purely on subscription cost versus salary cost, and you forget to price in what happens when the automated version meets a case it wasn't built for. For Klarna, that was an angry customer with a nuanced refund dispute. In engineering, it's a production incident the agent introduced and didn't flag — three weeks before anyone notices.</p>
<h2>Where the cost argument actually wins outright</h2>
<p>None of this means the cost case is wrong everywhere. It's right in one specific place: greenfield work, low stakes per mistake, human still firmly in the loop.</p>
<p>Solo-founder software businesses clearing seven and eight figures with single-digit or zero employees are a real, growing category in 2026. A few hundred dollars a month in agent subscriptions against an \(80,000–\)120,000-a-month human team isn't an exaggeration for that segment. It's just the math.</p>
<p>The same crowd is also the first to flag the real risk — and it's not the AI's coding ability. It's concentration of failure. Replace fifty employees with five hundred agents, and you don't become the CEO of a lean company. You become the single point of failure for every lawsuit, every hallucination, every 3am incident. That's a cost too. It just doesn't show up on the subscription invoice.</p>
<h2>What the job market is doing, not saying</h2>
<p>Surveys are cheap to answer dishonestly. Payroll data isn't.</p>
<p>Stanford HAI's 2026 AI Index, built on ADP payroll records rather than self-reported surveys, confirms it: employment for software developers aged 22–25 has fallen nearly 20% since late 2022 — the same window AI coding tools went mainstream. Developers over 30 in the same exposed roles grew employment 6–12% over that period.</p>
<p>But 2026 made the picture messier, not cleaner.</p>
<p>Salesforce CEO Marc Benioff confirmed the company isn't hiring additional software developers this fiscal year, crediting AI agents directly. In the same breath, he announced a push to hire 1,000 new college graduates — redirected toward sales and customer-facing roles, where AI hasn't displaced headcount. Read narrowly, that's not "AI replaced the juniors." It's "AI replaced one kind of junior work, and hiring moved to wherever that work doesn't exist yet."</p>
<p>IBM has kept its public commitment to triple US entry-level hiring, betting junior developers shift toward judgment-heavy, customer-facing work now that AI absorbs the repetitive part. Meanwhile, some labor economists are pushing back on the AI narrative entirely — pointing at interest-rate-driven hiring freezes and a brutal grad recruiting cycle as the more plausible cause, with AI serving as a convenient, simultaneously-timed scapegoat.</p>
<p>A year in, the more careful read: companies aren't cleanly eliminating junior developers. They're raising what "junior" has to mean on day one. AI-fluent juniors now command a premium north of 40% over generalists doing the job the old way.</p>
<h2>So, is it cheap enough?</h2>
<p>Cheap enough to replace a developer's typing? Yes. Has been for a while. Not a close call. A \(20–\)200/month tool against a quarter-million-dollar loaded engineer wins on cost every single time — even after this year's pricing shake-up made the entry-level numbers less honest than they used to be.</p>
<p>Cheap enough to replace a developer's judgment — the part that knows which benchmark cliff is about to bite you, catches the bug the agent confidently introduced, decides what shouldn't be automated at all? Not yet. The clearest evidence anyone built to answer that question collapsed under the weight of how fast adoption moved. The agency that ran it is now relying on self-reported numbers it has publicly told you to doubt.</p>
<p>The gap between "passes the public benchmark" and "survives contact with your actual codebase" is still tens of points wide, no matter which model you pick. And the models posting the highest scores right now aren't even available to use.</p>
<p>The teams getting real value out of this in 2026 aren't asking "can AI replace developers." They're asking which slice of the week is mechanical enough to hand off, and which slice is the part the salary was actually paying for.</p>
<p>Get that split right, and the cost question mostly answers itself.</p>
<blockquote>
<p>AI is cheap enough to replace a developer's typing, but not yet cheap enough to replace their judgment — and the evidence built to settle that question kept dissolving the moment researchers tried to pin it down.</p>
</blockquote>
<hr />
<p><em>Published via <a href="https://zyvop.com/is-ai-actually-cheap-enough-to-replace-developers-8i74u?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Can a $2,000 Mini PC Replace Your AI Cloud Bill?]]></title><description><![CDATA[Every AI agent demo looks impressive until the bill arrives.
A cloud-routed AI agent doing real work can cost \(10 to \)20 a day in API credits. That's not a one-time fee. It's a meter that runs every]]></description><link>https://blog.zyvop.com/can-a-2000-mini-pc-replace-your-ai-cloud-bill</link><guid isPermaLink="true">https://blog.zyvop.com/can-a-2000-mini-pc-replace-your-ai-cloud-bill</guid><category><![CDATA[aIInfrastructure]]></category><category><![CDATA[hermesagent]]></category><category><![CDATA[llamacpp]]></category><category><![CDATA[Localai]]></category><category><![CDATA[Self Hosted AI]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:52:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/2570b7a8-7d7d-46f4-b9a6-f22f8e270344.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every AI agent demo looks impressive until the bill arrives.</p>
<p>A cloud-routed AI agent doing real work can cost \(10 to \)20 a day in API credits. That's not a one-time fee. It's a meter that runs every day the agent stays useful.</p>
<p>The alternative gaining traction in 2026: stop renting the model. Own the box it runs on instead. A mini PC, on 24/7, hosting both the language model and the agent layer that drives it.</p>
<p>AMD's "Strix Halo" platform makes this practical. These chips pair a strong CPU with the largest integrated GPU AMD has shipped for a small form factor, fed by enough unified memory to hold genuinely large models.</p>
<p>Minisforum's <strong>MS-S1 MAX</strong> is one of several systems built on Strix Halo, alongside the Framework Desktop and boxes from Beelink and HP. Paired with <strong>Hermes Agent</strong> — an open-source autonomous agent maintained by Nous Research, now backed by NVIDIA's own DGX Spark integration — the combination answers a real question: can a business run its AI workflows without a recurring cloud bill?</p>
<p>Based on independent benchmarks and reviews of this hardware, the answer is mostly yes. There are real caveats worth knowing before you buy.</p>
<h2>The hardware: what Strix Halo actually offers</h2>
<p>The MS-S1 MAX runs on AMD's Ryzen AI Max+ 395: a 16-core/32-thread Zen 5 chip with a Radeon 8060S integrated GPU. That GPU has 40 RDNA 3.5 compute units — roughly the performance class of a discrete RX 7600 XT, built into the SoC instead of sitting on its own card.</p>
<p>Minisforum pairs it with a six-heatpipe, dual-fan cooler and a built-in 320W power supply. Continuous output sits around 110–130W, with peaks up to 160W depending on performance mode.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Spec</th>
</tr>
</thead>
<tbody><tr>
<td>CPU</td>
<td>16-core/32-thread Zen 5, up to 5.1 GHz boost</td>
</tr>
<tr>
<td>GPU</td>
<td>Radeon 8060S, 40 CU RDNA 3.5</td>
</tr>
<tr>
<td>NPU</td>
<td>XDNA2, 50 TOPS</td>
</tr>
<tr>
<td>Memory</td>
<td>LPDDR5X-8000, up to 128GB, quad-channel, ~256GB/s</td>
</tr>
<tr>
<td>I/O</td>
<td>Dual USB4 v2 (80Gbps), dual 10GbE, PCIe x16 for expansion</td>
</tr>
</tbody></table>
<p>The detail that matters most for LLMs isn't GPU speed. It's memory architecture.</p>
<p>Strix Halo's GPU shares the system's unified LPDDR5X pool instead of using dedicated VRAM. AMD's driver can allocate a large chunk of that pool as GPU-addressable memory via GTT (Graphics Translation Table). On a 128GB unit, that typically means up to 96GB usable by the GPU.</p>
<p>A reviewer at AkitaOnRails bought the Minisforum specifically to run models too big for any consumer GPU. Their framing: an RTX 5090 is several times faster per token, but it caps out at 32GB. Models that don't fit simply don't run there. Strix Halo's pitch is capacity, not speed, at a fraction of the cost and power draw of a professional GPU.</p>
<p>It's worth knowing the field. The same chip ships in Framework's Desktop (modular, repairable) and Strix Halo boxes from Beelink and HP — all in a similar performance bracket. One step up, the realistic alternative is Apple's Mac Studio with M3 Ultra: up to 512GB of unified memory at roughly 3x the bandwidth. That's for models too large for any Strix Halo box, at a correspondingly higher price.</p>
<p>AMD itself joined that lineup directly this month, launching its own first-party <strong>Ryzen AI Halo</strong> developer box — same Ryzen AI Max+ 395 chip, same 128GB ceiling, but built and sold by AMD as an explicit answer to NVIDIA's DGX Spark, in Windows 11 Pro or Linux SKUs. At $3,999 it's a real premium over the Minisforum/Framework/Beelink crowd, and the premium buys AMD's own validated software stack rather than any hardware advantage — it's the same silicon discussed throughout this piece. On the NVIDIA side, the headline Computex 2026 announcement, RTX Spark, isn't a DGX Spark successor — it's a Windows-on-ARM platform aimed at laptops and creator desktops, shipping this fall. For the specific always-on, headless-Linux use case here, DGX Spark remains NVIDIA's current answer, and nothing has displaced Strix Halo as AMD's.</p>
<h2>Setting up the model layer</h2>
<p>The software runs on <strong>llama.cpp</strong>. The GPU is driven through one of two backend paths: Vulkan (Mesa RADV or AMD's AMDVLK) or ROCm/HIP.</p>
<p>Independent 2026 testing across Strix Halo builds generally finds Vulkan/RADV the most stable path. ROCm sometimes wins on prompt processing for long contexts, but it takes more tuning to get there — driver pinning, environment overrides — and it's less reliable out of the box.</p>
<p>Getting the GPU to claim its full memory allocation takes one BIOS change: set minimum dedicated VRAM as low as the board allows. Then add a few AMDGPU kernel parameters so the driver doesn't cap the GTT allocation.</p>
<p>Running models full-GPU (<code>-ngl 999</code>) keeps the CPU free for everything else sharing the box — the agent process, the VPN daemon, a dashboard.</p>
<p>For anyone running more than one model size, <strong>llama-swap</strong> helps. It's a small Go binary, maintained by mostlygeek, that sits in front of <code>llama-server</code> and hot-swaps the loaded model based on the incoming request. A lighter model can serve quick replies while a larger one loads on demand for harder tasks, all behind one stable API endpoint.</p>
<h2>The performance reality check</h2>
<p>Worth triangulating across sources here, not just trusting one build log — the numbers below are pulled from several independently-run Strix Halo benchmark logs (linked below), not just the two reviews cited elsewhere in this piece.</p>
<p>Multiple independent 2026 benchmark logs converge on GPT-OSS-120B (~59GB at Q4) generating at <strong>53–56 tokens/second</strong> over Vulkan/RADV. That's consistent enough across reviewers to plan around, and close to what NVIDIA's own DGX Spark posts on comparable models.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Approx. size</th>
<th>Generation speed (Vulkan/RADV)</th>
</tr>
</thead>
<tbody><tr>
<td>GPT-OSS-120B (Q4)</td>
<td>~59GB</td>
<td>~53–56 t/s</td>
</tr>
<tr>
<td>GPT-OSS-20B (F16)</td>
<td>~13GB</td>
<td>~45–50 t/s</td>
</tr>
<tr>
<td>Qwen3-30B-A3B (MoE)</td>
<td>~57GB</td>
<td>~96–100 t/s</td>
</tr>
<tr>
<td>Dense 70B models (Llama 3.3, DeepSeek-R1-distill)</td>
<td>~40GB+</td>
<td>~5–6 t/s</td>
</tr>
</tbody></table>
<p>That last row matters more than the headline number. Dense (non-MoE) 70B-class models run dramatically slower than MoE models of similar size on this hardware. Token generation here is bandwidth-bound, and dense models activate far more parameters per token.</p>
<p>The AkitaOnRails review, on the same chip, reported high single-digit speeds for a 70B dense model. It also flagged ROCm bugs that, at the time, blocked some dense 70B+ models from running at all on that backend. Usable for batch jobs. Not for live chat.</p>
<blockquote>
<p>Use MoE models — GPT-OSS-120B, Qwen 3.6's 35B-A3B — as the local default. Treat large dense models as a slower, occasional-use tool, not the daily driver.</p>
</blockquote>
<p>One model family conspicuously missing from that table: <strong>Qwen 3.6</strong>. Alibaba released it in April 2026 — a 27B dense model and a 35B-A3B MoE variant — and it's specifically what NVIDIA's own Hermes/DGX Spark writeup recommends pairing with this class of hardware, not GPT-OSS. Early Strix Halo numbers back that recommendation: the dense Qwen3.6-27B at Q4 runs around 12 tokens/second on its own, but llama.cpp's new multi-token-prediction (MTP) support — merged in May 2026 — nearly doubles that to roughly 21 t/s on the same hardware. The 35B-A3B MoE variant lands in GPT-OSS-120B's speed class while using a fraction of the memory. If you're setting this up today, start with Qwen 3.6 as the default and treat GPT-OSS as the fallback, not the other way around.</p>
<p>Thermally, the platform handles 24/7 use well. Sustained loads around 110W with edge temperatures in the high 60s°C are typical on better-cooled Strix Halo boxes. That's the actual precondition for "leave it running in a closet" being safe rather than a fire risk.</p>
<h2>The workflow layer: Hermes Agent</h2>
<p>A fast local model isn't enough on its own. Something still needs to turn tokens into actions.</p>
<p><strong>Hermes Agent</strong> is an open-source autonomous agent maintained by Nous Research. It connects to any OpenAI-compatible model endpoint and handles what a raw model can't: persistent memory, scheduled jobs, tool calls, sub-agents, browser automation, and messaging integrations.</p>
<p>A request moving through the stack looks like this:</p>
<pre><code class="language-java">User
  ↓
Hermes Agent  (memory, tool calls, scheduling)
  ↓
OpenAI-compatible API  (llama-server / llama-swap)
  ↓
llama.cpp
  ↓
Local model
</code></pre>
<p>NVIDIA's RTX AI Garage team has since written about pairing Hermes with local hardware directly. That's a reasonable signal: "agent client talking to a local OpenAI-compatible server" is becoming a standard pattern, not a one-off hack — though notably, NVIDIA's own writeup pairs Hermes with Qwen 3.6, not GPT-OSS (see above).</p>
<p>Setup follows a consistent shape across documented builds:</p>
<ol>
<li><p><strong>Point Hermes at the local server.</strong> Choose a self-hosted/OpenAI-compatible provider during setup. Give it <code>localhost:&lt;port&gt;</code> where <code>llama-server</code> (or llama-swap) is listening. No API key needed.</p>
</li>
<li><p><strong>Make local the default, not the only option.</strong> Pair it with a fallback to a hosted provider — OpenRouter or a frontier-model API — for tasks needing more reasoning, speed, or context. Quick lookups don't need a 120B model. Heavy tool chains sometimes do benefit from a hosted model's larger context window.</p>
</li>
<li><p><strong>Run it as a service.</strong> Enable Hermes (and any VPN daemon) as a systemd service. It survives reboots without anyone babysitting a terminal.</p>
</li>
<li><p><strong>Reach it remotely.</strong> Tailscale's free Personal plan now supports up to six users with unlimited devices per tailnet. It's the common choice for reaching a closet PC from a laptop or phone without exposing the dashboard to the open internet.</p>
</li>
</ol>
<h2>What about the NPU?</h2>
<p>Strix Halo's 50-TOPS XDNA2 NPU mostly sits unused in these setups. That's still accurate as of mid-2026.</p>
<p><strong>FastFlowLM</strong>, the project building NPU-native inference for Ryzen AI chips, reports around 19 tokens/second running GPT-OSS-20B entirely on the NPU, at roughly 10x better power efficiency than GPU inference. Genuinely useful on a battery-constrained laptop. Less compelling on a plugged-in desktop, where the iGPU is already faster and power isn't the constraint.</p>
<p>FastFlowLM shipped Windows-first, but added official Linux support in March 2026 via Debian packages and AMD's Lemonade SDK — it's a supported <code>apt</code> install on Ubuntu now, not a community workaround. Pairing the NPU as a fast draft model alongside a larger GPU-served model is still mostly a DIY exercise, though. For a 24/7 desktop agent box, the NPU is currently a "nice to have," not a missing piece holding the setup back.</p>
<h2>The actual cost math</h2>
<p>This isn't free. Power, hardware amortization, and time spent maintaining drivers all cost something.</p>
<p>What changes is the shape of the cost. A fixed, predictable infrastructure cost replaces a variable per-token bill, one that scales with exactly the workloads you're trying to encourage the agent to take on.</p>
<p>At roughly 110W sustained, running a Strix Halo box continuously costs a few dollars a month in power at typical residential rates. That's a rounding error next to even modest cloud-agent credit spend. The bigger variable is upfront hardware cost. Pricing across the Strix Halo lineup is genuinely volatile — smaller-memory boxes start around \(1,500, while a fully loaded 128GB unit like the MS-S1 Max has listed anywhere from roughly \)2,300 to $3,000+ depending on retailer and promo timing (it ships in a single 128GB configuration, not a range of RAM tiers) — weighed against how much cloud spend it actually displaces.</p>
<p>Privacy doesn't depend on which box or model size you pick. Whatever model is loaded, API keys, customer data, and business workflows never leave the local network. That's true on a cheap machine running a tiny model, and true on a fully loaded MS-S1 MAX running a 120B one. The hardware just decides how much work stays local before something has to go to the cloud anyway.</p>
<h2>The verdict</h2>
<p>Good fit: steady, repeatable automation where privacy or cost predictability matters more than raw speed. Research summaries, content drafts, routine tool-calling chains — anything that can tolerate the agent thinking for tens of seconds instead of two.</p>
<p>Weaker fit: anything customer-facing and latency-sensitive, or workloads leaning on dense 70B+ models where driver maturity still has rough edges.</p>
<p>Treat the local model as the default path, and a hosted fallback as the exception-handling lane, not the other way around. The economics, and the realistic performance, both work out in the local box's favor.</p>
<p>The bill, this time, doesn't arrive.</p>
<hr />
<h3>Further reading</h3>
<ul>
<li><p><a href="https://www.servethehome.com/minisforum-ms-s1-max-review-the-best-ryzen-ai-max-mini-pc-yet/">ServeTheHome — Minisforum MS-S1 Max Review</a></p>
</li>
<li><p><a href="https://akitaonrails.com/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">AkitaOnRails — Review: Minisforum MS-S1 Max</a></p>
</li>
<li><p><a href="https://blogs.nvidia.com/blog/rtx-ai-garage-hermes-agent-dgx-spark/">NVIDIA RTX AI Garage — Hermes Agent on RTX PCs and DGX Spark</a></p>
</li>
<li><p><a href="https://github.com/mostlygeek/llama-swap">mostlygeek/llama-swap on GitHub</a></p>
</li>
<li><p><a href="https://github.com/hogeheer499-commits/strix-halo-guide">strix-halo-guide — community Strix Halo benchmark log (GitHub)</a></p>
</li>
<li><p><a href="https://calebcoffie.com/blog/benchmarking-llama-cpp-mtp-on-strix-halo">Caleb Coffie — Benchmarking llama.cpp's MTP support on Strix Halo</a></p>
</li>
<li><p><a href="https://qwen.ai/blog?id=qwen3.6-35b-a3b">Qwen3.6-35B-A3B release blog (Alibaba)</a></p>
</li>
<li><p><a href="https://fastflowlm.com/docs/install_lin/">FastFlowLM — Linux installation guide</a></p>
</li>
<li><p><a href="https://www.techpowerup.com/349943/pre-orders-for-usd-4000-amd-ryzen-ai-halo-mini-pc-dev-kits-go-live">TechPowerUp — AMD Ryzen AI Halo dev kit pre-orders go live</a></p>
</li>
<li><p><a href="https://nvidianews.nvidia.com/news/nvidia-microsoft-windows-pcs-agents-rtx-spark">NVIDIA/Microsoft — Introducing RTX Spark</a></p>
</li>
<li><p><a href="https://tailscale.com/docs/account/manage-plans/free-plans-discounts">Tailscale — Free pricing plans</a></p>
</li>
</ul>
<hr />
<p><em>Published via <a href="https://zyvop.com/untitled-draft-6n9od?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Background Jobs in NestJS with BullMQ: A Complete Walkthrough]]></title><description><![CDATA[Calling a slow, occasionally-unreliable upstream API — an LLM provider, a payment processor, a third-party webhook — directly inside a request handler ties that request's fate to the upstream call's f]]></description><link>https://blog.zyvop.com/background-jobs-in-nestjs-with-bullmq-a-complete-walkthrough</link><guid isPermaLink="true">https://blog.zyvop.com/background-jobs-in-nestjs-with-bullmq-a-complete-walkthrough</guid><category><![CDATA[AsyncProcessing]]></category><category><![CDATA[#backgroundjobs ]]></category><category><![CDATA[bullmq]]></category><category><![CDATA[idempotency]]></category><category><![CDATA[#JobQueue]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:51:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/000c9b01-9fe8-4435-9ff8-45d60c8b0ac7.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Calling a slow, occasionally-unreliable upstream API — an LLM provider, a payment processor, a third-party webhook — directly inside a request handler ties that request's fate to the upstream call's fate. If it's slow, the client waits on the full round trip. If it fails, the handler has to decide on the spot whether to fail the whole request or quietly fall back to something else, which is exactly the kind of decision that's easy to get wrong under pressure and hard to notice when it goes wrong silently.</p>
<p>This post builds a background job pipeline in NestJS using BullMQ: a request that enqueues work and returns immediately, a worker that processes it with automatic retries, and a durable record a client can poll for the outcome. It's demonstrated on an async draft-generation endpoint — the same shape as a real LLM-backed content pipeline — with a couple of details about BullMQ's actual runtime behavior that are easy to get wrong if you're going off the docs alone. The full project, tested end-to-end against real Postgres and Redis, is linked at the end.</p>
<h2>The shape of the problem</h2>
<p>A synchronous version of this endpoint looks simple:</p>
<pre><code class="language-typescript">@Post('drafts')
async create(@Body() dto: CreateDraftDto) {
  const content = await this.llmProvider.generate(dto.topic); // could take 5-30s, could fail
  return this.draftsRepository.save({ topic: dto.topic, content });
}
</code></pre>
<p>The request now blocks for as long as the LLM call takes, and a single transient failure (a timeout, a rate limit, a brief provider outage) becomes a failed request with nothing to show for it — no record that it was attempted, nothing to retry, nothing to inspect afterward.</p>
<p>The fix is to decouple "accept the request" from "do the work":</p>
<pre><code class="language-typescript">@Post('drafts')
create(@Body() dto: CreateDraftDto) {
  return this.draftsService.enqueue(dto); // returns in milliseconds
}
</code></pre>
<p><code>enqueue()</code> writes a <code>pending</code> row and hands the job to BullMQ. A separate worker process picks it up, retries it automatically on failure, and updates that row when it's done — successfully or not.</p>
<h2>Two sources of truth, on purpose</h2>
<p>This implementation keeps <strong>two</strong> records of a job's state, deliberately:</p>
<ul>
<li><p><strong>BullMQ's own state</strong>, in Redis — which attempt it's on, when it'll retry next, its position in the queue. This is operational state, and it's normal for it to get cleaned up after a job finishes (<code>removeOnComplete</code>/<code>removeOnFail</code>).</p>
</li>
<li><p><strong>A Postgres row</strong>, written by the application — <code>pending</code> → <code>processing</code> → <code>completed</code>/<code>failed</code>, plus the result or failure reason. This is what survives a Redis flush, what a client actually polls, and what you'd query for "show me every failed draft from last week."</p>
</li>
</ul>
<pre><code class="language-typescript">export enum DraftJobStatus {
  PENDING = 'pending',
  PROCESSING = 'processing',
  COMPLETED = 'completed',
  FAILED = 'failed',
}

@Entity()
export class DraftJob {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  topic: string;

  @Column({ type: 'varchar', default: DraftJobStatus.PENDING })
  status: DraftJobStatus;

  @Column({ type: 'text', nullable: true })
  result: string | null;

  @Column({ type: 'text', nullable: true })
  failureReason: string | null;

  @Column({ default: 0 })
  attemptsMade: number;

  @CreateDateColumn()
  createdAt: Date;

  @Column({ type: 'timestamp', nullable: true })
  completedAt: Date | null;
}
</code></pre>
<p>Conflating these two — treating BullMQ's Redis-backed job as the only record — means losing history the moment a job gets cleaned up, and gives clients nothing stable to poll against.</p>
<h2>Wiring BullMQ into the Nest app</h2>
<p>Before any of that, BullMQ needs a Redis connection — configured once, at the root module:</p>
<pre><code class="language-typescript">// app.module.ts
BullModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) =&gt; ({
    connection: {
      host: configService.get&lt;string&gt;('REDIS_HOST'),
      port: configService.get&lt;number&gt;('REDIS_PORT'),
    },
  }),
}),
</code></pre>
<p>Then, inside whichever feature module actually uses a queue, that queue gets declared by name:</p>
<pre><code class="language-typescript">// drafts.module.ts
BullModule.registerQueue({ name: 'draft-generation' }),
</code></pre>
<p>That string, <code>'draft-generation'</code>, is the thread tying three separate places together: it's what <code>registerQueue</code> declares here, what <code>@InjectQueue('draft-generation')</code> asks for in the service below, and what <code>@Processor('draft-generation')</code> listens on in the worker. All three have to use the exact same name — there's no compiler check enforcing that, since it's just a string. The two ways this can go wrong behave very differently, and it's worth knowing which is which rather than assuming:</p>
<ul>
<li><p>If <code>@InjectQueue</code> doesn't match anything <code>registerQueue</code> declared, Nest's dependency injection fails loudly at startup with an <code>UnknownDependenciesException</code> — I confirmed this directly, and the error message names the exact missing provider token and suggests the fix. The app won't boot at all, so this mistake gets caught immediately.</p>
</li>
<li><p>If <code>@Processor</code> doesn't match — while <code>@InjectQueue</code>/<code>registerQueue</code> still agree with each other — the app boots without any error, jobs enqueue successfully, and then just sit in the queue forever. I verified this too: enqueued a job against a correctly-wired producer, waited, and checked the queue directly — the job's state was still <code>waiting</code>, indefinitely, with nothing logged anywhere to indicate why. No worker was ever listening on that name. This is the genuinely silent failure mode worth watching for, since nothing about it looks broken until someone notices a backlog that never drains.</p>
</li>
</ul>
<h2>Enqueueing a job</h2>
<p>The job's payload type is defined right alongside the service that creates it — <code>enqueue()</code> and the worker (below) both depend on this shape:</p>
<pre><code class="language-typescript">export interface DraftJobData {
  draftJobId: string;
  topic: string;
  simulateFailures: number;
}
</code></pre>
<pre><code class="language-typescript">async enqueue(dto: CreateDraftDto) {
  const draftJob = await this.draftJobsRepository.save(
    this.draftJobsRepository.create({ topic: dto.topic, status: DraftJobStatus.PENDING }),
  );

  await this.draftQueue.add(
    'generate',
    { draftJobId: draftJob.id, topic: dto.topic, simulateFailures: dto.simulateFailures ?? 0 },
    {
      jobId: draftJob.id,
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: { age: 3600 },
      removeOnFail: { age: 86400 },
    },
  );

  return { id: draftJob.id, status: draftJob.status };
}
</code></pre>
<p>A few specific choices worth calling out:</p>
<p><code>jobId: draftJob.id</code> — using the Postgres row's own id as the BullMQ job id, rather than letting BullMQ generate one, is what makes re-enqueueing idempotent (more on this below).</p>
<p><code>backoff: { type: 'exponential', delay: 2000 }</code> — each retry waits roughly double the previous gap, rather than hammering a struggling upstream API at a fixed interval. With <code>attempts: 3</code> there are only ever <strong>two</strong> such gaps to observe, not three — I measured them directly against this exact config rather than trusting the formula from memory: ~2035ms after the first failure, ~4009ms after the second, and no fourth attempt ever fires once those three tries are exhausted. The delay keeps doubling if you raise <code>attempts</code> higher, but at <code>attempts: 3</code> specifically, a third gap is never reachable — that ceiling comes from <code>attempts</code>, not from <code>backoff</code> alone.</p>
<p><code>removeOnComplete</code><strong>/</strong><code>removeOnFail</code> — without these, BullMQ keeps every job's data in Redis indefinitely. These ages keep Redis from growing unbounded while still leaving failed jobs around longer (a day, vs. an hour for successes) since they're more likely to need debugging.</p>
<h2>The worker</h2>
<pre><code class="language-typescript">@Processor('draft-generation', { concurrency: 5 })
export class DraftGenerationProcessor extends WorkerHost {
  constructor(private readonly draftsService: DraftsService) {
    super();
  }

  async process(job: Job&lt;DraftJobData&gt;): Promise&lt;string&gt; {
    const { draftJobId, topic, simulateFailures } = job.data;
    await this.draftsService.markProcessing(draftJobId);

    // Throwing here is what tells BullMQ to retry, subject to `attempts`/`backoff`.
    return generateDraftContent(topic, job.attemptsMade, simulateFailures);
  }

  @OnWorkerEvent('completed')
  async onCompleted(job: Job&lt;DraftJobData&gt;, result: string) {
    await this.draftsService.markCompleted(job.data.draftJobId, result, job.attemptsMade);
  }

  @OnWorkerEvent('failed')
  async onFailed(job: Job&lt;DraftJobData&gt; | undefined, error: Error) {
    if (!job) return;
    const maxAttempts = job.opts.attempts ?? 1;
    if (job.attemptsMade &gt;= maxAttempts) {
      await this.draftsService.markFailed(job.data.draftJobId, job.attemptsMade, error.message);
    }
    // else: still has retries left, BullMQ will reschedule it automatically
  }
}
</code></pre>
<p><code>@Processor(...)</code> plus extending <code>WorkerHost</code> is the <code>@nestjs/bullmq</code> pattern for defining a worker — <code>process()</code> is the actual job handler, and <code>@OnWorkerEvent</code> hooks into BullMQ's lifecycle events. (The actual source file also logs each attempt via Nest's <code>Logger</code>, trimmed from the snippet above for readability — the logic shown is otherwise unchanged from what's in the repo.)</p>
<h3>The detail that's easy to get backwards: <code>attemptsMade</code></h3>
<p>BullMQ's <code>'failed'</code> event fires after <strong>every</strong> failed attempt, not just the last one. If you write the failure-handling logic without checking attempt count, a job that's about to succeed on its third try gets incorrectly marked <code>failed</code> after its first.</p>
<p>The fix is the <code>job.attemptsMade &gt;= maxAttempts</code> check above — but getting that comparison right depends on knowing exactly what <code>attemptsMade</code> contains at each point, which isn't obvious from the type signature alone. I checked this directly against a running BullMQ instance rather than assuming:</p>
<pre><code class="language-yaml">attemptLog (job.attemptsMade as seen INSIDE the processor on each run):
[ { attemptsMade: 0, opts: 3 },
  { attemptsMade: 1, opts: 3 },
  { attemptsMade: 2, opts: 3 } ]
final 'completed' event attemptsMade: 3
</code></pre>
<p><code>attemptsMade</code> is <strong>0</strong> on the very first execution, not 1 — it counts completed prior attempts, not the current attempt number. Inside <code>process()</code>, a job configured with <code>attempts: 3</code> sees <code>0</code>, <code>1</code>, <code>2</code> across its three tries; the <code>'completed'</code>/<code>'failed'</code> event handlers see it afterward, already incremented to <code>3</code>. Getting this backwards (checking <code>attemptsMade &lt;= maxAttempts</code> instead of <code>&gt;=</code>, or assuming attempt 1 reads as <code>1</code> inside the processor) produces a retry condition that's off by exactly one — either giving up one attempt early, or never giving up at all.</p>
<h2>What "idempotent" actually means here</h2>
<p>Using the Postgres row's id as the BullMQ <code>jobId</code> means re-enqueueing the same id is a no-op — but it's worth being precise about what that actually does, rather than taking it on faith. I tested this directly:</p>
<pre><code class="language-javascript">await queue.add('task', { n: 1 }, { jobId: 'fixed-id' }); // creates the job
await queue.add('task', { n: 2 }, { jobId: 'fixed-id' }); // returns a Job object, but...

const stored = await queue.getJob('fixed-id');
console.log(stored.data); // { n: 1 } — the SECOND add() never took effect
</code></pre>
<p>The second <code>add()</code> call doesn't throw, and it doesn't error — it just silently does nothing. The job already in Redis under that id keeps its original data. This holds whether the original job is still waiting, actively processing, or has already completed (as long as it hasn't been cleaned up by <code>removeOnComplete</code>).</p>
<p>That's exactly the property you want for a reconciliation or retry path elsewhere in your own backend: if something upstream of <code>enqueue()</code> ever calls it twice for the same logical request — a retried HTTP call, a duplicate webhook delivery, a race in a distributed system — the second call doesn't create a second job, doesn't reprocess, and doesn't overwrite the first job's data with whatever the second call happened to pass. It's a much cheaper idempotency guarantee than building your own deduplication table, but it only works because the <code>jobId</code> is something stable and meaningful (the Postgres row's id) rather than an auto-generated one.</p>
<h2>Setting up and running the project</h2>
<p>Clone the repo, install dependencies, and copy the environment template:</p>
<pre><code class="language-bash">git clone &lt;your-repo-url&gt;
cd bullmq-nestjs-demo
npm install
cp .env.example .env
</code></pre>
<p>This project needs both Postgres and Redis running locally — Postgres for the durable job records, Redis for BullMQ's own queue state. The fastest path for both is Docker:</p>
<pre><code class="language-bash">docker run --name jobs-postgres \
  -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=jobs_demo \
  -p 5432:5432 -d postgres:16

docker run --name jobs-redis -p 6379:6379 -d redis:7
</code></pre>
<p>The defaults in <code>.env.example</code> already line up with those two containers, so you shouldn't need to edit <code>.env</code> if you used them as-is. Then start the API:</p>
<pre><code class="language-bash">npm run start:dev
</code></pre>
<p><code>synchronize: true</code> is enabled in <code>app.module.ts</code> for this demo, so the <code>draft_job</code> table is created automatically on first boot — no manual migration step needed to follow along. (Turn that off and switch to real migrations before deploying anywhere real.) The worker runs inside this same process for simplicity here; see the production notes below on why you'd typically split it out.</p>
<p>With the server up on <code>http://localhost:3000</code>, you're ready to run through the flow below.</p>
<h2>Testing it end-to-end</h2>
<p>This is the actual sequence run against a live server, real Postgres, and real Redis before publishing — happy path, retry-then-succeed, permanent failure, and the validation/not-found edge cases:</p>
<pre><code class="language-bash"># Happy path — no simulated failures
curl -X POST http://localhost:3000/drafts \
  -H "Content-Type: application/json" \
  -d '{"topic":"NestJS background jobs"}'
# -&gt; { "id": "...", "status": "pending" }

curl http://localhost:3000/drafts/&lt;id&gt;
# -&gt; { "status": "completed", "attemptsMade": 1, "result": "..." }
</code></pre>
<pre><code class="language-bash"># Forces 2 failures before success — watch attemptsMade end at 3,
# with ~2s then ~4s of exponential backoff between attempts
curl -X POST http://localhost:3000/drafts \
  -H "Content-Type: application/json" \
  -d '{"topic":"Retry demo","simulateFailures":2}'

curl http://localhost:3000/drafts/&lt;id&gt;
# (poll a few times over ~6-8s)
# -&gt; { "status": "completed", "attemptsMade": 3, "result": "..." }
</code></pre>
<pre><code class="language-bash"># More failures than the configured 3 attempts allow — exercises the
# permanent-failure path instead
curl -X POST http://localhost:3000/drafts \
  -H "Content-Type: application/json" \
  -d '{"topic":"Permanent failure demo","simulateFailures":5}'

curl http://localhost:3000/drafts/&lt;id&gt;
# -&gt; { "status": "failed", "attemptsMade": 3, "failureReason": "Simulated upstream failure..." }
</code></pre>
<pre><code class="language-bash"># Validation and not-found paths, checked the same way
curl -X POST http://localhost:3000/drafts \
  -H "Content-Type: application/json" \
  -d '{"topic":"a"}'
# -&gt; 400 Bad Request — topic is shorter than the DTO's @MinLength(3)

curl http://localhost:3000/drafts/00000000-0000-0000-0000-000000000000
# -&gt; 404 { "message": "Draft job not found" }
</code></pre>
<p>Every path above — immediate success, eventual success after retries, permanent failure after exhausting retries, a rejected validation error, and an unknown id — was checked against the actual <code>status</code>, <code>attemptsMade</code>, and HTTP status code returned by a live server, not just "the request didn't error." The duplicate-<code>jobId</code> idempotency behavior from the previous section was verified the same way, against this same running instance, using a small script that called <code>queue.add()</code> directly rather than going through the HTTP API (since triggering a raw duplicate enqueue isn't something a normal client request can do on its own).</p>
<h2>Production notes</h2>
<p>A few things were deliberately simplified for this demo and are worth tightening before shipping:</p>
<ul>
<li><p><strong>Run the worker as a separate process from the API.</strong> Here, the processor lives inside the same NestJS app as the controller — fine for a demo, but in production a traffic spike on the HTTP side will compete for CPU with job processing unless they're split into independent, independently-scalable deployments.</p>
</li>
<li><p><strong>Distinguish retryable from non-retryable errors.</strong> A timeout or a 429 should retry. A 401 from a bad API key will fail identically on every attempt — <code>throw new UnrecoverableError(...)</code> (exported by BullMQ) skips the remaining retries instead of wasting them on a guaranteed failure.</p>
</li>
<li><p><strong>Size</strong> <code>concurrency</code> <strong>to what the upstream and your database can actually sustain.</strong> <code>concurrency: 5</code> here is a demo default, not a number derived from real capacity.</p>
</li>
<li><p><strong>Monitor queue depth, not just individual job outcomes.</strong> A queue that's silently backing up faster than it's draining is a different failure mode than any single job failing, and won't show up by looking at one job's status at a time.</p>
</li>
<li><p><strong>There's no authentication on these endpoints.</strong> This demo is scoped to the queueing mechanics, not access control — anyone who can reach <code>POST /drafts</code> can enqueue work, and anyone who knows (or guesses) a job id can read its result. If this sits behind a real API, put it behind the same kind of auth boundary as anything else you wouldn't want publicly writable (a prior post on this blog covers building TOTP-based 2FA in NestJS, if that's useful context for the kind of guard logic involved).</p>
</li>
</ul>
<h2><a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/bullmq-nestjs-demo">Source code</a></h2>
<p>The complete, tested implementation — NestJS module, the worker, entity, DTOs, and a README with the full setup and curl walkthrough — is available as a standalone repository: <code>bullmq-nestjs-demo</code>. Clone it, point it at your own Postgres and Redis, and the enqueue → retry → status-poll flow above works out of the box.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/background-jobs-in-nestjs-with-bullmq-a-complete-walkthrough-yltes?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Rate Limiting Alone Won't Stop a Patient Attacker]]></title><description><![CDATA[@nestjs/throttler counts requests per IP address within a time window. That's the entire mechanism — no concept of accounts, passwords, or "this one person is being targeted." Point it at a login endp]]></description><link>https://blog.zyvop.com/rate-limiting-alone-wont-stop-a-patient-attacker</link><guid isPermaLink="true">https://blog.zyvop.com/rate-limiting-alone-wont-stop-a-patient-attacker</guid><category><![CDATA[account lockout]]></category><category><![CDATA[#apisecurity]]></category><category><![CDATA[brute force protection]]></category><category><![CDATA[login security]]></category><category><![CDATA[nestjs]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:50:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/b337974a-94e1-423d-bd12-37a41558ad40.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>@nestjs/throttler</code> counts requests per IP address within a time window. That's the entire mechanism — no concept of accounts, passwords, or "this one person is being targeted." Point it at a login endpoint and it will correctly stop a script hammering <code>/login</code> a hundred times a second. It will do nothing against someone guessing a password once every few minutes, or spreading attempts across a dozen IPs, because that was never the problem it's built to solve.</p>
<p>Below is both halves of a real defense: the IP throttle for volume, and a small Redis-backed service that locks by <em>email</em> for everything the throttle misses. Every response shape, status code, and timing claim here came from actually running the requests, not from the docs — including one interaction between two timers that will quietly undo a lockout's own "fresh start" if you're not paying attention to how they relate.</p>
<h2>The throttle, and what it actually returns</h2>
<p>A baseline policy on every route, plus a stricter override on login specifically:</p>
<pre><code class="language-typescript">// app.module.ts — global baseline: 20 requests/60s per IP, everywhere
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 20 }]),
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
</code></pre>
<pre><code class="language-typescript">// auth.controller.ts — login gets a stricter limit than the rest of the API
@Throttle({ default: { limit: 10, ttl: 60000 } })
@Post('login')
async login(@Body() dto: LoginDto) { /* ... */ }
</code></pre>
<p>Whether that override <em>replaces</em> the global 20/60s or stacks on top of it isn't obvious from reading the decorator, so it's worth confirming rather than guessing: it replaces. A route-level <code>@Throttle</code> with the same policy name (<code>default</code>) fully takes over for that route. Eleven rapid requests to <code>/auth/register</code> (no override, rides the global policy) all came back 201; the same eleven against <code>/auth/login</code> would have tripped its 10-request limit well before the last one.</p>
<p>The response headers on a request that's still under the limit:</p>
<pre><code class="language-yaml">X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 60
</code></pre>
<p>And once it's crossed:</p>
<pre><code class="language-json">// HTTP 429
{ "statusCode": 429, "message": "ThrottlerException: Too Many Requests" }
</code></pre>
<p>That's the library's real, unmodified default — not a placeholder for something cleaner. It reads like a stringified exception because that's more or less what it is, and it's worth routing through <code>@nestjs/throttler</code>'s exception factory if this needs to match the rest of an API's error shape.</p>
<h2>Where the throttle's job ends</h2>
<p>None of that knows or cares who's logging in. Ten requests a minute from one IP against <code>alice@example.com</code> and ten requests a minute from ten different IPs, one attempt each, all against <code>alice@example.com</code>, look completely different to a per-IP counter — the second pattern never trips it, no matter how long it continues. That's the gap a second, IP-independent mechanism has to cover.</p>
<h2>Locking the account, not the address</h2>
<pre><code class="language-typescript">async recordFailure(email: string): Promise&lt;FailureResult&gt; {
  const key = this.attemptsKey(email);
  const attempts = await this.redis.incr(key);
  if (attempts === 1) {
    await this.redis.expire(key, this.windowSeconds);
  }

  if (attempts &gt;= this.maxAttempts) {
    await this.redis.set(this.lockKey(email), '1', 'EX', this.lockoutSeconds);
    return { attempts, locked: true, retryAfterSeconds: this.lockoutSeconds };
  }

  return { attempts, locked: false };
}

async recordSuccess(email: string): Promise&lt;void&gt; {
  await this.redis.del(this.attemptsKey(email), this.lockKey(email));
}
</code></pre>
<p><code>INCR</code> against a key that doesn't exist yet starts it at 1, so there's no separate setup step for a first failure. The counter's expiry is set once — only when <code>attempts === 1</code> — so a streak of failures within one window doesn't keep pushing its own deadline out; it's one streak, with one expiry, not a self-renewing one.</p>
<p>Both key-building helpers lowercase the email first:</p>
<pre><code class="language-typescript">private attemptsKey(email: string): string {
  return `login-attempts:${email.toLowerCase()}`;
}
</code></pre>
<p>Skipping that normalization would open a real gap, not just a style nitpick — so rather than assume it works, this was tested directly: three failures as <code>dana@example.com</code>, then two more as <code>Dana@Example.COM</code>, a different casing entirely. The Redis counter read 5 afterward, under a single key, and a sixth attempt in yet another casing (<code>DANA@EXAMPLE.COM</code>) — this time with the <em>correct</em> password — still came back locked. Without the <code>.toLowerCase()</code>, those three casings would have been three separate five-strike budgets instead of one.</p>
<h3>Two timers that look independent and aren't</h3>
<p>The service has two separate durations: how long the failure counter itself lives (<code>windowSeconds</code>), and how long an actual lock lasts once triggered (<code>lockoutSeconds</code>). Treating them as unrelated knobs is the natural first instinct, and it's wrong — tested directly, with a 10-second window and a 6-second lock:</p>
<pre><code class="language-yaml">lock status right after the 6s lock expires: { locked: false }
attempts count at that same moment:          5
</code></pre>
<p>The lock is gone, but the counter — on its own, longer, 10-second clock — hasn't caught up yet. It's still sitting at 5. One more failed attempt right then doesn't start over at 1; it pushes the existing counter to 6, still over the 5-attempt threshold, and the account locks again immediately:</p>
<pre><code class="language-yaml">result of one failure right after unlock: { attempts: 6, locked: true }
</code></pre>
<p>So "unlocked" didn't mean "clean slate" here — it meant "one more mistake and you're back in." That might be exactly the behavior you want (harsher consequences for someone who fails again right after a lockout), but it should be a choice, not a side effect of two numbers that happened to get picked independently. Defaulting <code>LOGIN_LOCKOUT_WINDOW_SECONDS</code> and <code>LOGIN_LOCKOUT_DURATION_SECONDS</code> to the <em>same</em> value is what makes the out-of-the-box behavior a genuine reset: by the time the lock is gone, so is the counter it was based on. Tested with matched timers instead of mismatched ones, the positive case is exactly as boring as it should be — wait out the lock, and a correct password just works:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -&gt; 201 { "accessToken": "..." }   (no failures logged anywhere afterward)
</code></pre>
<h3>The failure that triggers a lock doesn't announce it</h3>
<pre><code class="language-typescript">try {
  const user = await this.authService.validatePassword(dto.email, dto.password);
  await this.loginAttemptService.recordSuccess(dto.email);
  return { accessToken: this.authService.issueToken(user) };
} catch (err) {
  await this.loginAttemptService.recordFailure(dto.email);
  throw err; // same error whether or not this failure just triggered a lock
}
</code></pre>
<p>Four wrong-password attempts return four identical 401s. The fifth — the one that actually crosses the threshold — returns that <em>same</em> 401, not a warning. The lock only becomes visible on whatever comes next, correct password included. Confirmed end to end: four 401s, a fifth 401 that locked the account behind the scenes, and only the sixth request got the 429. Nothing in that fifth response tells anyone it was the last try, which is one less signal an attacker gets to calibrate around.</p>
<h2>Both mechanisms, same request</h2>
<pre><code class="language-bash"># alice fails 5 times, then even the right password is rejected
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"wrong-password"}'
# -&gt; 401  (x5)

curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -&gt; 429 {"statusCode":429,"message":"Account temporarily locked...","reason":"ACCOUNT_LOCKED"}

# bob, meanwhile, is entirely unaffected
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"correct-horse-battery"}'
# -&gt; 201 { "accessToken": "..." }
</code></pre>
<p>The lockout 429 and the throttler's 429 share a status code but not a body — <code>"reason":"ACCOUNT_LOCKED"</code> versus the generic <code>ThrottlerException</code> message — so nothing downstream has to guess which mechanism fired.</p>
<p>One sequencing detail if you're replicating this yourself: the IP throttle counts <em>every</em> call to <code>/login</code> in its window, not just the ones in whatever you'd mentally group as "the throttle test." Running the lockout sequence above first, then immediately sending a batch of requests to check the throttle, tripped the 429 on the third request of that batch rather than somewhere near the tenth — because the eight requests just spent on the lockout scenario were already sitting in the same 60-second window. Not a bug, just a shared counter that doesn't know your test plan has phases.</p>
<h2>Running it</h2>
<p>Needs Postgres and Redis; Docker is the fastest path to both:</p>
<pre><code class="language-bash">git clone &lt;your-repo-url&gt; &amp;&amp; cd rate-limiting-nestjs-demo
npm install &amp;&amp; cp .env.example .env
docker run --name rl-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=ratelimit_demo -p 5432:5432 -d postgres:16
docker run --name rl-redis -p 6379:6379 -d redis:7
npm run start:dev
</code></pre>
<p>The shipped defaults (5 attempts, a 15-minute window and lock) are real production numbers, not demo placeholders, but they're still a starting point rather than a universal answer — how forgiving to be with a legitimate user who fumbles their password a few times is a judgment call specific to what's being protected. Lower <code>LOGIN_LOCKOUT_WINDOW_SECONDS</code>/<code>LOGIN_LOCKOUT_DURATION_SECONDS</code> in your own <code>.env</code> if you want to watch a lock expire without an actual 15-minute wait.</p>
<p>What's deliberately not here: the in-memory throttle storage this demo uses doesn't share counters across multiple app instances, so a horizontally-scaled deployment needs a shared store (<code>@nestjs/throttler</code> supports pluggable backends, Redis included) or the limit quietly stops meaning what it says. There's also no lockout notification to the account owner, and no CAPTCHA as a third layer for public-facing forms — both reasonable additions, both left out here to keep the two mechanisms that <em>are</em> built easy to see clearly.</p>
<p>The full project — this module, the lockout service, and a README with the complete setup and curl walkthrough — is in the <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/rate-limiting-nestjs-demo">rate-limiting-nestjs-demo</a> repository alongside this post.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/rate-limiting-alone-won-t-stop-a-patient-attacker-ybcz1?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Passwordless Login With Magic Links in Node.js]]></title><description><![CDATA[I've seen developers implement magic link auth four different ways and get it wrong three of them. The broken versions all share the same flaw: they use a regular get + del to verify tokens, which mea]]></description><link>https://blog.zyvop.com/passwordless-login-with-magic-links-in-nodejs</link><guid isPermaLink="true">https://blog.zyvop.com/passwordless-login-with-magic-links-in-nodejs</guid><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:50:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/945c0c6e-4941-41c6-9964-4df8ddaa4bdf.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've seen developers implement magic link auth four different ways and get it wrong three of them. The broken versions all share the same flaw: they use a regular <code>get</code> + <code>del</code> to verify tokens, which means two simultaneous requests with the same token both pass.</p>
<p>User A clicks the link in Gmail. The prefetch scanner in their email client hits it half a second earlier. Server issues two sessions. Neither user knows, but one of them is now authenticated in a context they didn't control.</p>
<p>This post builds it right. By the end you'll have a working Node.js implementation with proper atomic token verification, email enumeration protection, an Ethereal fallback so you can see the full flow without any SMTP configuration, and 19 tests that run without Redis or a real mail server.</p>
<p>Source code: <a href="http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth">http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth</a></p>
<hr />
<h2>The one decision that matters most</h2>
<p>Before touching any code: the difference between a safe magic link implementation and a broken one is a single Redis command.</p>
<p>The broken pattern:</p>
<pre><code class="language-js">const email = await redis.get(key);
if (email) await redis.del(key);
return email;
</code></pre>
<p>Between the <code>get</code> and the <code>del</code>, another request can read the same key. Both get a valid email back, both get sessions issued.</p>
<p><code>getDel</code> collapses that into one atomic operation:</p>
<pre><code class="language-js">const email = await redis.getDel(key);
return email ?? null;
</code></pre>
<p>Redis executes this as a single command. No window — one request gets the email, any concurrent request gets <code>null</code>.</p>
<p>That's why Redis fits this problem better than a Postgres row. You'd need a transaction and an advisory lock to get the same guarantee from a relational database.</p>
<p>The full verify function adds a length check before the Redis call. A token that's too short or too long gets rejected immediately, no round-trip needed:</p>
<pre><code class="language-js">// src/lib/token.js
import crypto from "node:crypto";

const TOKEN_PREFIX = "magic:";
const TOKEN_TTL_SECONDS = 15 * 60;
const TOKEN_BYTES = 32;

export async function createToken(redis, email) {
  const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
  await redis.set(TOKEN_PREFIX + token, email.toLowerCase(), { EX: TOKEN_TTL_SECONDS });
  return token;
}

export async function verifyToken(redis, token) {
  if (!token || typeof token !== "string" || token.length !== TOKEN_BYTES * 2) {
    return null;
  }
  return (await redis.getDel(TOKEN_PREFIX + token)) ?? null;
}
</code></pre>
<p><code>crypto.randomBytes(32)</code> gives 256 bits of entropy — 64 hex characters. Brute-forcing that against a 15-minute window isn't happening. The email gets lowercased on creation so <code>User@Example.COM</code> and <code>user@example.com</code> don't end up as separate keys that never match.</p>
<p>Redis also handles expiry natively via the <code>EX</code> option. No cron job to clean up stale tokens, no <code>WHERE expires_at &lt; NOW()</code> queries — they disappear on their own.</p>
<hr />
<h2>What the user actually sees</h2>
<p>The sign-in page is minimal HTML with one real piece of JavaScript: it handles the <code>?error=link_invalid</code> query param that the server redirects to when a token has expired or already been used.</p>
<pre><code class="language-xml">&lt;!-- public/index.html (abbreviated) --&gt;
&lt;form id="form"&gt;
  &lt;input type="email" id="email" placeholder="you@example.com" required&gt;
  &lt;button type="submit"&gt;Send sign-in link&lt;/button&gt;
&lt;/form&gt;
&lt;div id="msg" class="message"&gt;&lt;/div&gt;

&lt;script&gt;
  const ERRORS = {
    link_invalid: "That link has expired or already been used. Please request a new one.",
    default: "Something went wrong. Please try again."
  };

  // Show error from redirect after a failed verify attempt
  const errKey = new URLSearchParams(location.search).get("error");
  if (errKey) showMsg(ERRORS[errKey] ?? ERRORS.default, "error");

  document.getElementById("form").addEventListener("submit", async (e) =&gt; {
    e.preventDefault();
    const email = document.getElementById("email").value.trim();
    const btn = e.target.querySelector("button");

    btn.disabled = true;
    btn.textContent = "Sending…";

    const res = await fetch("/auth/request", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email })
    });

    const data = await res.json();

    if (res.ok) {
      showMsg("Check your inbox — a sign-in link is on its way.", "success");
      document.getElementById("form").style.display = "none";
    } else {
      showMsg(data.error ?? ERRORS.default, "error");
      btn.disabled = false;
      btn.textContent = "Send sign-in link";
    }
  });
&lt;/script&gt;
</code></pre>
<p>The <code>?error=link_invalid</code> flow matters more than it looks. When verification fails — expired token, already used, someone typed a URL wrong — it redirects to <code>/?error=link_invalid</code> instead of returning a JSON 400.</p>
<p>Users arrive at <code>/auth/verify</code> by clicking a link in their email client, not via a fetch call. A JSON error on a blank page is a dead end; a redirect back to the sign-in form with a clear message is something a person can act on.</p>
<hr />
<h2>Sending the email without configuring SMTP</h2>
<p>Production email uses <code>SMTP_HOST</code>, <code>SMTP_PORT</code>, <code>SMTP_USER</code>, and <code>SMTP_PASS</code>. Without those, the mailer silently creates an Ethereal test account and logs a preview URL. Ethereal is a real catch-all SMTP service — emails don't deliver anywhere, but you can open the URL and see the full rendered email, including the magic link button, right in your browser.</p>
<pre><code class="language-js">// src/lib/mailer.js
import nodemailer from "nodemailer";

let transporter = null;

async function getTransporter() {
  if (transporter) return transporter;

  if (process.env.SMTP_HOST) {
    transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: Number(process.env.SMTP_PORT) || 587,
      secure: process.env.SMTP_SECURE === "true",
      auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
    });
  } else {
    const testAccount = await nodemailer.createTestAccount();
    transporter = nodemailer.createTransport({
      host: "smtp.ethereal.email",
      port: 587,
      auth: { user: testAccount.user, pass: testAccount.pass }
    });
    console.log(`[mailer] Ethereal test account: ${testAccount.user}`);
  }

  return transporter;
}

export async function sendMagicLink(to, magicUrl) {
  const transport = await getTransporter();

  const info = await transport.sendMail({
    from: process.env.EMAIL_FROM || '"Magic Link Auth" &lt;no-reply@example.com&gt;',
    to,
    subject: "Your sign-in link",
    text: `Sign-in link (expires in 15 minutes, single use):\n\n${magicUrl}`,
    html: `
      &lt;p&gt;Click below to sign in. Expires in &lt;strong&gt;15 minutes&lt;/strong&gt;, single use.&lt;/p&gt;
      &lt;p style="margin:24px 0"&gt;
        &lt;a href="${magicUrl}" style="background:#111;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none"&gt;
          Sign in
        &lt;/a&gt;
      &lt;/p&gt;
      &lt;p style="color:#999;font-size:12px"&gt;Didn't request this? Safe to ignore.&lt;/p&gt;
    `
  });

  if (!process.env.SMTP_HOST) {
    console.log(`[mailer] Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
  }
}
</code></pre>
<p>The transporter is cached in a module-level variable. <code>nodemailer.createTestAccount()</code> makes a real HTTP call to Ethereal — you don't want that per-request.</p>
<p>For production, Resend and Postmark are the cleaner options over raw SMTP. They handle deliverability, bounce handling, and SPF/DKIM automatically. Hook them in via the <code>SMTP_HOST</code> and friends in <code>.env</code>.</p>
<hr />
<h2>Sessions, cookies, and why SameSite: lax is correct here</h2>
<p>After verification, the token is gone and the user needs something that persists across requests. A JWT in an httpOnly cookie is the right shape: stateless (no server-side session table), survives page refreshes, inaccessible to JavaScript running on the page.</p>
<pre><code class="language-js">// src/lib/session.js
import jwt from "jsonwebtoken";

const COOKIE_NAME = "session";
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;

function getSecret() {
  const secret = process.env.JWT_SECRET;
  if (!secret || secret.length &lt; 32) {
    throw new Error("JWT_SECRET must be set and at least 32 characters long");
  }
  return secret;
}

export function issueSession(res, email) {
  const token = jwt.sign({ email }, getSecret(), { expiresIn: SESSION_TTL_SECONDS });
  res.cookie(COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: SESSION_TTL_SECONDS * 1000
  });
}

export function requireAuth(req, res, next) {
  const token = req.cookies?.[COOKIE_NAME];
  if (!token) return res.status(401).json({ error: "Not authenticated." });

  try {
    const payload = jwt.verify(token, getSecret());
    req.user = { email: payload.email };
    next();
  } catch {
    res.clearCookie(COOKIE_NAME);
    return res.status(401).json({ error: "Session expired. Please sign in again." });
  }
}
</code></pre>
<p><code>sameSite: "lax"</code> needs explaining here because it interacts directly with how magic links work. When a user clicks a link in their email client, that's a top-level navigation — the browser follows it like a normal page load. <code>Lax</code> allows the cookie to be sent on those top-level navigations from external origins.</p>
<p><code>Strict</code> would block that, requiring another sign-in if a user arrives from any external link. <code>None</code> requires HTTPS everywhere and opens CSRF exposure. <code>Lax</code> is the right call for this pattern.</p>
<p><code>secure</code> only goes on in production. Without that conditional, local development over HTTP would silently fail to set the cookie and you'd spend an hour wondering why sessions don't persist.</p>
<hr />
<h2>The two routes that do the work</h2>
<pre><code class="language-js">// src/routes/auth.js
import { Router } from "express";
import { createToken, verifyToken } from "../lib/token.js";
import { sendMagicLink as defaultSendMagicLink } from "../lib/mailer.js";
import { issueSession } from "../lib/session.js";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function createAuthRouter(redis, { sendMagicLink = defaultSendMagicLink } = {}) {
  const router = new Router();

  router.post("/request", async (req, res) =&gt; {
    const email = (req.body?.email ?? "").trim().toLowerCase();

    if (!EMAIL_RE.test(email)) {
      return res.status(400).json({ error: "A valid email address is required." });
    }

    try {
      const token = await createToken(redis, email);
      const appUrl = process.env.APP_URL || `http://localhost:${process.env.PORT || 3000}`;
      await sendMagicLink(email, `\({appUrl}/auth/verify?token=\){token}`);
      res.json({ ok: true, message: "Check your inbox for a sign-in link." });
    } catch (err) {
      console.error("[auth] request failed:", err);
      res.status(500).json({ error: "Failed to send the sign-in link. Please try again." });
    }
  });

  router.get("/verify", async (req, res) =&gt; {
    const email = await verifyToken(redis, req.query.token);
    if (!email) return res.redirect("/?error=link_invalid");
    issueSession(res, email);
    res.redirect("/dashboard");
  });

  router.post("/logout", (req, res) =&gt; {
    res.clearCookie("session");
    res.redirect("/");
  });

  return router;
}
</code></pre>
<p><code>/request</code> returns <code>200</code> whether the email exists in your system or not. A 404 for unknown emails would tell anyone who tries that an address isn't registered — a user enumeration leak. The response is always "check your inbox," whether you're a real user or someone probing your database.</p>
<p>The <code>sendMagicLink</code> function is passed in as a default parameter, not imported at the top. That's the dependency injection hook tests use — swap it for a no-op, no SMTP connection required.</p>
<hr />
<h2>Running the whole thing</h2>
<pre><code class="language-bash"># Start Redis (Docker is fastest)
docker run -d -p 6379:6379 redis:alpine

# Install deps and start
npm install &amp;&amp; cp .env.example .env &amp;&amp; npm start
</code></pre>
<p>The server needs at minimum a <code>JWT_SECRET</code> in <code>.env</code>. Generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p><strong>Request a link:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/request \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'
</code></pre>
<pre><code class="language-json">{ "ok": true, "message": "Check your inbox for a sign-in link." }
</code></pre>
<p>Your server console will print something like:</p>
<pre><code class="language-csharp">[mailer] Preview URL: https://ethereal.email/message/WaQKMgKddxQDoou
</code></pre>
<p>Open that URL, click the "Sign in" button in the rendered email. You'll land on the dashboard. The token is gone — clicking the link again redirects to <code>/?error=link_invalid</code>.</p>
<p><strong>Check your session after clicking (saves cookie to</strong> <code>cookies.txt</code><strong>):</strong></p>
<pre><code class="language-bash">curl -c cookies.txt -b cookies.txt http://localhost:3000/api/me
</code></pre>
<pre><code class="language-json">{ "email": "you@example.com" }
</code></pre>
<p><strong>Invalid email format:</strong></p>
<pre><code class="language-bash">curl -s -X POST http://localhost:3000/auth/request \
  -H "Content-Type: application/json" \
  -d '{"email": "notanemail"}'
</code></pre>
<pre><code class="language-json">{ "error": "A valid email address is required." }
</code></pre>
<p><strong>Rate limiter — 5 requests per 15 minutes per IP, sixth gets a 429:</strong></p>
<pre><code class="language-bash">for i in $(seq 1 6); do
  curl -s -o /dev/null -w "request $i -&gt; %{http_code}\n" \
    -X POST http://localhost:3000/auth/request \
    -H "Content-Type: application/json" \
    -d '{"email":"test@example.com"}'
done
</code></pre>
<pre><code class="language-rust">request 1 -&gt; 200
request 2 -&gt; 200
request 3 -&gt; 200
request 4 -&gt; 200
request 5 -&gt; 200
request 6 -&gt; 429
</code></pre>
<p>Five is tight enough to stop inbox flooding and loose enough that a real user who typo'd their address gets a few retries.</p>
<p><strong>Expired or already-used token:</strong></p>
<pre><code class="language-bash">curl -v "http://localhost:3000/auth/verify?token=$( python3 -c 'print("a"*64)')" 2&gt;&amp;1 | grep "Location:"
</code></pre>
<pre><code class="language-go">&lt; Location: /?error=link_invalid
</code></pre>
<hr />
<h2>Tests</h2>
<p>19 tests, no Redis or SMTP connection needed. The token suite runs against a plain in-memory Map mimicking the Redis interface; the session suite sets <code>JWT_SECRET</code> in <code>before()</code> and cleans up after; the route suite injects the no-op mailer:</p>
<pre><code class="language-bash">npm test
</code></pre>
<pre><code class="language-python"># tests 19
# pass  19
# fail   0
</code></pre>
<p>If you want to verify the atomic single-use behavior beyond the unit test, run the server with a real Redis instance and hit <code>/auth/verify</code> with the same token from two <code>curl</code> commands fired in parallel:</p>
<pre><code class="language-bash">TOKEN="paste-a-real-token-from-console-here"
curl "http://localhost:3000/auth/verify?token=$TOKEN" &amp;
curl "http://localhost:3000/auth/verify?token=$TOKEN" &amp;
wait
</code></pre>
<p>One will redirect to <code>/dashboard</code>. The other will redirect to <code>/?error=link_invalid</code>. That's <code>getDel</code> doing its job.</p>
<hr />
<h2>Before going live</h2>
<p>Set <code>NODE_ENV=production</code> — the <code>Secure</code> cookie flag only activates in production, and without HTTPS the cookie won't be sent by browsers at all. Point <code>REDIS_URL</code> at a managed instance (Upstash has a free tier and works well with this setup). Swap Ethereal for a transactional email provider; Resend has a generous free tier and a clean Node.js SDK. Put the whole thing behind nginx or Caddy for TLS termination.</p>
<p>One thing the repo doesn't include but you'll want eventually: a <code>users</code> table or equivalent. Right now any email can request a link and get a session — there's no concept of "registered users." Adding a check in <code>/request</code> that verifies the email exists in your database before sending the link is one line, but the shape of that check depends on your stack.</p>
<p>Get the code: <a href="http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth">http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth</a></p>
<hr />
<p><em>Published via <a href="https://zyvop.com/passwordless-login-with-magic-links-in-node-js-2r287?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Python Scraping at Scale: Distributed Crawling Across Multiple Machines (2026)]]></title><description><![CDATA[Introduction: When One Machine Is No Longer Enough
Most Python developers write simple scrapers—requests, BeautifulSoup, a loop, CSV writer—just to get data once or twice. When scaling or running them]]></description><link>https://blog.zyvop.com/python-scraping-at-scale-distributed-crawling-across-multiple-machines-2026</link><guid isPermaLink="true">https://blog.zyvop.com/python-scraping-at-scale-distributed-crawling-across-multiple-machines-2026</guid><category><![CDATA[architecture]]></category><category><![CDATA[python distributed web scraping]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[scale python scraper cloud]]></category><category><![CDATA[scrapy cluster tutorial 2026]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:49:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/9ace9422-006b-4f30-8021-150b01e7a3d4.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Introduction: When One Machine Is No Longer Enough</h2>
<p>Most Python developers write simple <code>scrapers—requests</code>, BeautifulSoup, a loop, CSV writer—just to get data once or twice. When scaling or running them over months, the real challenge is building a robust, scheduled system, not the parsing logic. The extraction code is tiny; the critical components are the queue, cache, storage, block detection, recovery loop, and exporters that keep the scraper alive against real‑world internet conditions.</p>
<p>At small scale, a single async Python process can handle thousands of pages per hour. But when you need to <code>crawl millions of pages per day</code> — competitor catalogues, job markets, news archives, e-commerce databases — a single machine hits hard limits: one IP address, one CPU, one point of failure.</p>
<p>Distributed scraping involves spreading tasks across multiple machines to increase speed and volume. Throttling and introducing random delays between requests can help to prevent IP bans, while rotating proxies help distribute requests and avoid detection. Managing sessions and leveraging parallel processing can further enhance efficiency.</p>
<p>This guide shows you exactly how to build a distributed scraping system that runs across multiple machines — using <code>scrapy-redis</code> for shared request queues, Docker for containerisation, and a production proxy management layer that survives real-world conditions.</p>
<hr />
<h2>The Architecture: One Queue, Many Workers</h2>
<p>The core insight behind distributed scraping is simple: replace Scrapy's in-memory request queue with a shared Redis queue that every worker machine can read from.</p>
<pre><code class="language-python">┌─────────────────────────────────────────────────────────────┐
│                    DISTRIBUTED SCRAPER                      │
│                                                             │
│  Master                                                     │
│  ┌──────────┐    Seeds requests    ┌─────────┐             │
│  │  Spider  │──────────────────────▶│  Redis  │             │
│  │ (seed)   │                      │  Queue  │             │
│  └──────────┘                      └────┬────┘             │
│                                         │                   │
│  Workers (any number of machines)       │                   │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │ Worker 1 │  │ Worker 2 │  │ Worker N │  ← pull jobs     │
│  │ (spider) │  │ (spider) │  │ (spider) │                  │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘                  │
│       │              │              │                        │
│       └──────────────┴──────────────┘                       │
│                          │                                   │
│                    ┌─────▼──────┐                           │
│                    │  MongoDB   │  ← all workers write here │
│                    │ (results)  │                           │
│                    └────────────┘                           │
└─────────────────────────────────────────────────────────────┘
</code></pre>
<p><code>scrapy-redis</code> lets multiple spider instances across machines pull from the same request queue with deduplication and job scheduling. This is the standard approach for large-scale web scraping that Python teams use in production.</p>
<hr />
<h2>Part 1: Setting Up scrapy-redis</h2>
<pre><code class="language-bash">pip install scrapy scrapy-redis redis pymongo
</code></pre>
<h3>The distributed spider</h3>
<pre><code class="language-python"># spiders/distributed_product_spider.py
import scrapy
from scrapy_redis.spiders import RedisSpider
from urllib.parse import urljoin
from datetime import datetime, timezone

class DistributedProductSpider(RedisSpider):
    """
    A Scrapy spider that pulls start URLs from a Redis list
    instead of a hardcoded start_urls list.

    To start crawling, push seed URLs to Redis:
        redis-cli lpush products:start_urls "https://example-store.com/products/"

    Any number of workers running this spider will cooperatively
    process the shared queue — each URL is processed exactly once.
    """

    name         = "distributed_products"
    redis_key    = "products:start_urls"   # Redis list to pop URLs from

    # How many URLs to pop from Redis at once per worker
    redis_batch_size = 16

    # Spider-level settings — override settings.py per spider
    custom_settings = {
        "CONCURRENT_REQUESTS":            32,
        "CONCURRENT_REQUESTS_PER_DOMAIN": 8,
        "DOWNLOAD_DELAY":                 0.5,
        "RANDOMIZE_DOWNLOAD_DELAY":       True,
        "AUTOTHROTTLE_ENABLED":           True,
        "AUTOTHROTTLE_TARGET_CONCURRENCY": 16.0,
        "AUTOTHROTTLE_MAX_DELAY":         5.0,
        "RETRY_TIMES":                    3,
        "RETRY_HTTP_CODES":              [429, 500, 502, 503, 504],
    }

    def parse(self, response):
        """
        Parse a product listing page.
        Yields product detail requests AND discovers pagination links.
        """
        # Follow product links to detail pages
        for href in response.css("a.product-link::attr(href)").getall():
            yield response.follow(href, callback=self.parse_product)

        # Auto-discover pagination — push next page back to Redis queue
        next_page = response.css("a[rel='next']::attr(href)").get()
        if next_page:
            # Use follow() to handle relative URLs
            yield response.follow(next_page, callback=self.parse)

    def parse_product(self, response):
        """Extract product data from a detail page."""
        yield {
            "url":        response.url,
            "title":      response.css("h1::text").get("").strip(),
            "price":      response.css(".price::text").get("").strip(),
            "sku":        response.css("[data-sku]::attr(data-sku)").get(),
            "in_stock":   bool(response.css(".in-stock")),
            "description":response.css(".product-description::text").get("").strip()[:500],
            "scraped_at": datetime.now(timezone.utc).isoformat(),
            "worker_id":  self.settings.get("WORKER_ID", "unknown"),
        }
</code></pre>
<h3>settings.py for distributed mode</h3>
<pre><code class="language-python"># settings.py

BOT_NAME = "distributed_scraper"
SPIDER_MODULES = ["spiders"]

# ── scrapy-redis settings ─────────────────────────────────────
SCHEDULER            = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS     = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_URL            = "redis://redis-host:6379"

# Keep crawl state across restarts — resumable crawls
SCHEDULER_PERSIST    = True

# How long to wait for new URLs before worker shuts down
SCHEDULER_IDLE_BEFORE_CLOSE = 30

# ── MongoDB pipeline ──────────────────────────────────────────
ITEM_PIPELINES = {
    "pipelines.MongoPipeline":        100,
    "pipelines.DuplicateFilterPipeline": 50,
}
MONGO_URI      = "mongodb://mongo-host:27017/"
MONGO_DATABASE = "distributed_scrape"

# ── Concurrency ───────────────────────────────────────────────
CONCURRENT_REQUESTS              = 32
CONCURRENT_REQUESTS_PER_DOMAIN   = 8
DOWNLOAD_DELAY                   = 0.5
RANDOMIZE_DOWNLOAD_DELAY         = True

# ── Retry ─────────────────────────────────────────────────────
RETRY_ENABLED    = True
RETRY_TIMES      = 3
RETRY_HTTP_CODES = [429, 500, 502, 503, 504, 522, 524]

# ── Logging ───────────────────────────────────────────────────
LOG_LEVEL = "INFO"

# ── Downloader middlewares ────────────────────────────────────
DOWNLOADER_MIDDLEWARES = {
    "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None,
    "middlewares.RotatingProxyMiddleware": 350,
    "middlewares.UserAgentRotationMiddleware": 400,
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
}

# ── User agent pool ───────────────────────────────────────────
USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/119.0.0.0 Safari/537.36",
    "Mozilla/5.0 (X11; Linux x86_64) Chrome/118.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
]
</code></pre>
<hr />
<h2>Part 2: Production Middlewares</h2>
<pre><code class="language-python"># middlewares.py
import random
import logging
from scrapy import signals
from scrapy.exceptions import NotConfigured

logger = logging.getLogger(__name__)

class UserAgentRotationMiddleware:
    """Rotate User-Agent on every request."""

    def __init__(self, user_agents):
        self.user_agents = user_agents

    @classmethod
    def from_crawler(cls, crawler):
        agents = crawler.settings.getlist("USER_AGENTS")
        if not agents:
            raise NotConfigured("USER_AGENTS not set")
        return cls(agents)

    def process_request(self, request, spider):
        request.headers["User-Agent"] = random.choice(self.user_agents)

class RotatingProxyMiddleware:
    """
    Rotate through a proxy pool.
    Tracks failures per proxy and removes bad proxies from the pool.
    """

    def __init__(self, proxies, max_failures=5):
        self.proxies      = list(proxies)
        self.failures     = {}
        self.max_failures = max_failures

    @classmethod
    def from_crawler(cls, crawler):
        proxies = crawler.settings.getlist("PROXY_LIST", [])
        if not proxies:
            raise NotConfigured("PROXY_LIST is empty")
        return cls(proxies)

    def process_request(self, request, spider):
        if not self.proxies:
            return  # No proxies left — run without
        proxy = random.choice(self.proxies)
        request.meta["proxy"] = proxy

    def process_response(self, request, response, spider):
        if response.status in (403, 407, 429):
            proxy = request.meta.get("proxy")
            self._mark_failure(proxy)
        return response

    def process_exception(self, request, exception, spider):
        proxy = request.meta.get("proxy")
        self._mark_failure(proxy)

    def _mark_failure(self, proxy):
        if not proxy:
            return
        self.failures[proxy] = self.failures.get(proxy, 0) + 1
        if self.failures[proxy] &gt;= self.max_failures:
            if proxy in self.proxies:
                self.proxies.remove(proxy)
                logger.warning(f"Removed bad proxy: {proxy} ({self.max_failures} failures)")

class BlockDetectionMiddleware:
    """
    Detect common block patterns and trigger retries with a different proxy.
    """

    BLOCK_PATTERNS = [
        "access denied", "captcha", "blocked", "forbidden",
        "unusual traffic", "robot", "automated queries",
        "cf-browser-verification", "ddos-guard",
    ]

    def process_response(self, request, response, spider):
        body_lower = response.text[:2000].lower()

        is_blocked = (
            response.status in (403, 429, 503) or
            any(p in body_lower for p in self.BLOCK_PATTERNS) or
            len(response.text) &lt; 300
        )

        if is_blocked:
            logger.warning(f"Block detected on {request.url} — retrying")
            request.meta["proxy"]    = None  # Force new proxy on retry
            request.dont_filter      = True
            return request           # Re-schedule the request

        return response
</code></pre>
<hr />
<h2>Part 3: MongoDB Pipeline with Bulk Writes</h2>
<pre><code class="language-python"># pipelines.py
import logging
from datetime import datetime, timezone
from pymongo import MongoClient, UpdateOne
from pymongo.errors import BulkWriteError
from itemadapter import ItemAdapter

logger = logging.getLogger(__name__)

class DuplicateFilterPipeline:
    """Track seen URLs in memory to drop duplicates before DB write."""

    def open_spider(self, spider):
        self.seen_urls = set()

    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        url = adapter.get("url", "")
        if url in self.seen_urls:
            from scrapy.exceptions import DropItem
            raise DropItem(f"Duplicate URL: {url}")
        self.seen_urls.add(url)
        return item

class MongoPipeline:
    """
    Write scraped items to MongoDB using bulk operations.
    Upserts on URL — safe to re-run without creating duplicates.
    """

    BULK_SIZE  = 200   # Flush every 200 items
    COLLECTION = "products"

    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db  = mongo_db
        self._buffer   = []

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get("MONGO_URI", "mongodb://localhost:27017/"),
            mongo_db =crawler.settings.get("MONGO_DATABASE", "scrapy_data"),
        )

    def open_spider(self, spider):
        self.client     = MongoClient(self.mongo_uri)
        self.col        = self.client[self.mongo_db][self.COLLECTION]
        self.col.create_index("url", unique=True)
        self.items_written = 0
        logger.info(f"MongoDB connected: {self.mongo_db}.{self.COLLECTION}")

    def close_spider(self, spider):
        if self._buffer:
            self._flush()
        self.client.close()
        logger.info(f"MongoDB closed. Total items written: {self.items_written}")

    def process_item(self, item, spider):
        self._buffer.append(dict(item))
        if len(self._buffer) &gt;= self.BULK_SIZE:
            self._flush()
        return item

    def _flush(self):
        ops = [
            UpdateOne({"url": doc["url"]}, {"$set": doc}, upsert=True)
            for doc in self._buffer
        ]
        try:
            result = self.col.bulk_write(ops, ordered=False)
            count  = result.upserted_count + result.modified_count
            self.items_written += count
            logger.info(f"Flushed {len(self._buffer)} items → MongoDB")
        except BulkWriteError as e:
            logger.error(f"Bulk write error: {e.details.get('writeErrors', [])[:2]}")
        finally:
            self._buffer.clear()
</code></pre>
<hr />
<h2>Part 4: Docker Compose — Multi-Worker Setup</h2>
<pre><code class="language-yaml"># docker-compose.yml
version: "3.9"

x-worker-base: &amp;worker-base
  build: .
  volumes:
    - .:/app
  environment:
    - REDIS_URL=redis://redis:6379
    - MONGO_URI=mongodb://mongo:27017/
    - PROXY_LIST=${PROXY_LIST}
  depends_on:
    - redis
    - mongo
  restart: unless-stopped

services:

  # ── Infrastructure ──────────────────────────────────────────
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru
    volumes: ["redis_data:/data"]

  mongo:
    image: mongo:7
    ports: ["27017:27017"]
    volumes: ["mongo_data:/data/db"]

  # ── Scrapy Workers ──────────────────────────────────────────
  # Run as many of these as you have cores / IPs
  worker-1:
    &lt;&lt;: *worker-base
    command: &gt;
      scrapy crawl distributed_products
      -s WORKER_ID=worker-1
      -s CONCURRENT_REQUESTS=16
    environment:
      - REDIS_URL=redis://redis:6379
      - MONGO_URI=mongodb://mongo:27017/
      - WORKER_ID=worker-1

  worker-2:
    &lt;&lt;: *worker-base
    command: &gt;
      scrapy crawl distributed_products
      -s WORKER_ID=worker-2
      -s CONCURRENT_REQUESTS=16
    environment:
      - REDIS_URL=redis://redis:6379
      - MONGO_URI=mongodb://mongo:27017/
      - WORKER_ID=worker-2

  worker-3:
    &lt;&lt;: *worker-base
    command: &gt;
      scrapy crawl distributed_products
      -s WORKER_ID=worker-3
      -s CONCURRENT_REQUESTS=16
    environment:
      - REDIS_URL=redis://redis:6379
      - MONGO_URI=mongodb://mongo:27017/
      - WORKER_ID=worker-3

  # ── Seed Service — pushes start URLs into Redis ─────────────
  seeder:
    &lt;&lt;: *worker-base
    command: python seeder.py
    restart: "no"   # Run once then exit

volumes:
  redis_data:
  mongo_data:
</code></pre>
<hr />
<h2>Part 5: The Seeder — Feeding URLs Into the Queue</h2>
<pre><code class="language-python"># seeder.py
import redis
import time
import sys
from urllib.parse import urlencode

REDIS_URL   = "redis://localhost:6379"
REDIS_KEY   = "products:start_urls"

def seed_from_list(urls: list[str], batch_size: int = 500):
    """Push a list of start URLs into the Redis queue."""
    r = redis.from_url(REDIS_URL)

    # Clear existing queue if resuming fresh
    existing = r.llen(REDIS_KEY)
    if existing &gt; 0:
        print(f"Queue already has {existing:,} URLs. Adding to it.")

    pushed = 0
    for i in range(0, len(urls), batch_size):
        batch = urls[i:i + batch_size]
        r.rpush(REDIS_KEY, *batch)
        pushed += len(batch)
        print(f"Seeded {pushed:,}/{len(urls):,} URLs")

    print(f"\nDone. Redis queue '{REDIS_KEY}' has {r.llen(REDIS_KEY):,} URLs.")

def seed_paginated_site(
    base_url: str,
    start_page: int = 1,
    end_page: int = 500,
    page_param: str = "page"
):
    """Generate paginated URLs and push to Redis."""
    urls = []
    for page in range(start_page, end_page + 1):
        params = {page_param: page}
        urls.append(f"{base_url}?{urlencode(params)}")

    seed_from_list(urls)

def monitor_queue():
    """Monitor queue depth and worker progress in real time."""
    r = redis.from_url(REDIS_URL)
    print("Monitoring queue depth (Ctrl+C to stop)...")
    try:
        while True:
            depth   = r.llen(REDIS_KEY)
            seen    = r.scard(f"{REDIS_KEY}:dupefilter") or 0
            print(f"  Queue: {depth:,} pending | Seen: {seen:,} processed", end="\r")
            time.sleep(2)
    except KeyboardInterrupt:
        print("\nMonitor stopped.")

if __name__ == "__main__":
    mode = sys.argv[1] if len(sys.argv) &gt; 1 else "seed"

    if mode == "seed":
        seed_paginated_site(
            base_url  = "https://example-store.com/products",
            start_page = 1,
            end_page   = 1000,
        )
    elif mode == "monitor":
        monitor_queue()
    elif mode == "clear":
        r = redis.from_url(REDIS_URL)
        r.delete(REDIS_KEY)
        print(f"Queue '{REDIS_KEY}' cleared.")
</code></pre>
<hr />
<h2>Part 6: Scaling to Kubernetes</h2>
<p>For truly large-scale crawling across dozens of machines, Kubernetes is the standard deployment target. The key insight: each Scrapy worker is a stateless pod that reads from the shared Redis queue.</p>
<pre><code class="language-yaml"># k8s/scrapy-worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scrapy-workers
  labels:
    app: scrapy-worker
spec:
  replicas: 10   # Start with 10 workers; scale up/down with kubectl
  selector:
    matchLabels:
      app: scrapy-worker
  template:
    metadata:
      labels:
        app: scrapy-worker
    spec:
      containers:
        - name: scrapy-worker
          image: your-registry/scrapy-worker:latest
          command:
            - scrapy
            - crawl
            - distributed_products
          env:
            - name: REDIS_URL
              valueFrom:
                secretKeyRef:
                  name: scraper-secrets
                  key: redis-url
            - name: MONGO_URI
              valueFrom:
                secretKeyRef:
                  name: scraper-secrets
                  key: mongo-uri
            - name: WORKER_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name  # Pod name as worker ID
          resources:
            requests:
              memory: "256Mi"
              cpu:    "250m"
            limits:
              memory: "512Mi"
              cpu:    "500m"
</code></pre>
<p>Scale workers up or down instantly:</p>
<pre><code class="language-bash"># Scale to 20 workers
kubectl scale deployment scrapy-workers --replicas=20

# Check worker status
kubectl get pods -l app=scrapy-worker

# View logs from all workers
kubectl logs -l app=scrapy-worker --tail=50

# Auto-scale based on Redis queue depth (requires custom metrics)
kubectl autoscale deployment scrapy-workers --min=2 --max=50
</code></pre>
<hr />
<h2>Part 7: Proxy Management at Scale</h2>
<p>For serious scraping in 2026, residential proxies are almost always the safer option. Proxy quality matters far more than proxy quantity. A smaller pool of clean residential IPs usually performs much better than massive low-quality networks.</p>
<p>Here's a production proxy manager with health checking:</p>
<pre><code class="language-python"># proxy_manager.py
import asyncio
import httpx
import random
import time
import json
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class ProxyHealth:
    url:              str
    success_count:    int   = 0
    failure_count:    int   = 0
    last_used:        float = 0.0
    last_success:     float = 0.0
    avg_response_ms:  float = 0.0
    is_banned:        bool  = False

    @property
    def success_rate(self) -&gt; float:
        total = self.success_count + self.failure_count
        return self.success_count / total if total &gt; 0 else 0.0

    @property
    def score(self) -&gt; float:
        """Composite score: higher = better proxy to use."""
        if self.is_banned:
            return 0.0
        recency_bonus = max(0, 1 - (time.time() - self.last_success) / 3600)
        speed_score   = max(0, 1 - self.avg_response_ms / 5000)
        return self.success_rate * 0.6 + recency_bonus * 0.2 + speed_score * 0.2

class ProxyPool:
    """
    Intelligent proxy pool with health tracking and weighted selection.
    Workers register success/failure, pool learns which proxies are best.
    """

    BAN_THRESHOLD = 0.2   # Mark as banned if success rate drops below 20%

    def __init__(self, proxy_urls: list[str]):
        self.proxies = {url: ProxyHealth(url=url) for url in proxy_urls}

    def get_proxy(self, strategy: str = "weighted") -&gt; Optional[str]:
        """Select a proxy using the specified strategy."""
        available = [
            p for p in self.proxies.values()
            if not p.is_banned
        ]

        if not available:
            return None

        if strategy == "random":
            return random.choice(available).url

        elif strategy == "weighted":
            # Weight by score — best proxies get used more often
            scores = [max(p.score, 0.01) for p in available]
            total  = sum(scores)
            weights = [s / total for s in scores]
            return random.choices(available, weights=weights)[0].url

        elif strategy == "round_robin":
            # Sort by last_used timestamp — use least recently used
            available.sort(key=lambda p: p.last_used)
            return available[0].url

        return available[0].url

    def report_success(self, proxy_url: str, response_ms: float):
        if proxy_url in self.proxies:
            p = self.proxies[proxy_url]
            p.success_count  += 1
            p.last_used       = time.time()
            p.last_success    = time.time()
            # Rolling average response time
            p.avg_response_ms = (p.avg_response_ms * 0.8 + response_ms * 0.2)

    def report_failure(self, proxy_url: str):
        if proxy_url in self.proxies:
            p = self.proxies[proxy_url]
            p.failure_count += 1
            p.last_used      = time.time()
            # Auto-ban proxies with very low success rate
            if p.failure_count &gt; 10 and p.success_rate &lt; self.BAN_THRESHOLD:
                p.is_banned = True
                print(f"  Proxy banned (success rate {p.success_rate:.0%}): {proxy_url}")

    def get_stats(self) -&gt; dict:
        active  = [p for p in self.proxies.values() if not p.is_banned]
        banned  = [p for p in self.proxies.values() if p.is_banned]
        avg_sr  = sum(p.success_rate for p in active) / len(active) if active else 0

        return {
            "total":    len(self.proxies),
            "active":   len(active),
            "banned":   len(banned),
            "avg_success_rate": f"{avg_sr:.1%}",
            "best_proxy": max(active, key=lambda p: p.score).url if active else None,
        }

async def health_check_proxies(pool: ProxyPool, test_url: str = "https://httpbin.org/ip"):
    """
    Periodically check all proxies and un-ban those that have recovered.
    Run this as a background task.
    """
    async with httpx.AsyncClient(timeout=10) as client:
        for url, proxy in list(pool.proxies.items()):
            if not proxy.is_banned:
                continue
            try:
                start = time.time()
                r = await client.get(test_url, proxies={"https": url})
                if r.status_code == 200:
                    elapsed_ms    = (time.time() - start) * 1000
                    proxy.is_banned = False
                    pool.report_success(url, elapsed_ms)
                    print(f"  Proxy recovered: {url}")
            except Exception:
                pass   # Still banned

    stats = pool.get_stats()
    print(f"Proxy pool: {stats['active']} active, {stats['banned']} banned, "
          f"avg success rate: {stats['avg_success_rate']}")
</code></pre>
<hr />
<h2>Part 8: Monitoring Your Distributed Crawl</h2>
<pre><code class="language-python"># monitor.py — real-time crawl progress dashboard
import redis
import time
import json
from pymongo import MongoClient
from datetime import datetime

def live_dashboard(redis_url: str, mongo_uri: str, refresh_seconds: int = 5):
    """Print a live crawl progress dashboard to the terminal."""
    r   = redis.from_url(redis_url)
    db  = MongoClient(mongo_uri)["distributed_scrape"]

    start_time   = time.time()
    prev_count   = 0

    try:
        while True:
            # Queue stats
            queue_depth  = r.llen("products:start_urls")
            seen_count   = r.scard("products:start_urls:dupefilter") or 0

            # DB stats
            items_stored = db["products"].count_documents({})
            items_delta  = items_stored - prev_count
            rate_per_min = items_delta * (60 / refresh_seconds)
            prev_count   = items_stored

            # Worker stats (scrapy-redis stores worker heartbeats)
            workers = r.smembers("scrapy:workers") or set()

            # Elapsed
            elapsed = time.time() - start_time
            h, m    = divmod(int(elapsed), 3600)
            m, s    = divmod(m, 60)

            print(f"\033[2J\033[H")   # Clear screen
            print(f"{'═'*55}")
            print(f"  DISTRIBUTED SCRAPE MONITOR — {datetime.now().strftime('%H:%M:%S')}")
            print(f"{'═'*55}")
            print(f"  Runtime:       {h:02d}h {m:02d}m {s:02d}s")
            print(f"  Active workers:{len(workers)}")
            print(f"{'─'*55}")
            print(f"  Queue depth:   {queue_depth:&gt;10,}  (URLs remaining)")
            print(f"  URLs seen:     {seen_count:&gt;10,}  (deduplicated total)")
            print(f"  Items stored:  {items_stored:&gt;10,}  (in MongoDB)")
            print(f"  Rate:          {rate_per_min:&gt;10.0f}  items/minute")
            print(f"{'─'*55}")

            if rate_per_min &gt; 0 and queue_depth &gt; 0:
                eta_mins = queue_depth / rate_per_min
                h2, m2   = divmod(int(eta_mins * 60), 3600)
                m2, s2   = divmod(m2, 60)
                print(f"  ETA:           {h2:02d}h {m2:02d}m  (estimated)")

            print(f"{'═'*55}")
            time.sleep(refresh_seconds)

    except KeyboardInterrupt:
        print("\nMonitor stopped.")

if __name__ == "__main__":
    live_dashboard(
        redis_url="redis://localhost:6379",
        mongo_uri="mongodb://localhost:27017/",
    )
</code></pre>
<hr />
<h2>Part 9: Resumable Crawls</h2>
<p>One of the biggest advantages of <code>scrapy-redis</code> is that crawls are inherently resumable. If a worker crashes or you need to add more workers mid-crawl, simply restart:</p>
<pre><code class="language-bash"># Start a fresh crawl
python seeder.py seed

# Launch workers (they'll pick up from where they left off if SCHEDULER_PERSIST=True)
docker-compose up --scale worker=5

# Pause all workers (Ctrl+C in docker-compose)
# Resume later — queue state is preserved in Redis
docker-compose up --scale worker=10   # Can add more workers
</code></pre>
<p>To completely reset a crawl:</p>
<pre><code class="language-bash"># Clear the queue and deduplication filter
redis-cli del products:start_urls
redis-cli del products:start_urls:dupefilter
python seeder.py seed   # Re-seed with fresh URLs
</code></pre>
<hr />
<h2>Performance Numbers: What to Expect</h2>
<p>One machine first, then scale out. Tune Scrapy performance optimization settings before throwing hardware at the problem — raise <code>CONCURRENT_REQUESTS</code>, turn on <code>AUTOTHROTTLE</code>, and enable HTTP caching. If one server can't handle scraping millions of pages, set up a shared queue for distributed crawling across multiple workers.</p>
<p>Real-world throughput benchmarks:</p>
<table>
<thead>
<tr>
<th>Setup</th>
<th>Pages/hour</th>
<th>Cost estimate</th>
</tr>
</thead>
<tbody><tr>
<td>1 worker, no proxy</td>
<td>~8,000</td>
<td>Free</td>
</tr>
<tr>
<td>1 worker + 10 proxies</td>
<td>~25,000</td>
<td>~$5/day</td>
</tr>
<tr>
<td>5 workers + 50 proxies</td>
<td>~120,000</td>
<td>~$20/day</td>
</tr>
<tr>
<td>20 workers + 200 proxies</td>
<td>~500,000</td>
<td>~$80/day</td>
</tr>
<tr>
<td>100 workers (Kubernetes)</td>
<td>~2,500,000</td>
<td>~$350/day</td>
</tr>
</tbody></table>
<hr />
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Component</th>
<th>Tool</th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Spider</td>
<td>Scrapy + scrapy-redis</td>
<td>Crawl logic + distributed request handling</td>
</tr>
<tr>
<td>Queue</td>
<td>Redis</td>
<td>Shared URL queue with built-in deduplication</td>
</tr>
<tr>
<td>Worker deployment</td>
<td>Docker Compose / Kubernetes</td>
<td>Horizontal scale, stateless workers</td>
</tr>
<tr>
<td>Proxy management</td>
<td>Custom ProxyPool</td>
<td>Health-tracked, weighted proxy selection</td>
</tr>
<tr>
<td>Storage</td>
<td>MongoDB (bulk upserts)</td>
<td>Centralised, deduplicated results</td>
</tr>
<tr>
<td>Monitoring</td>
<td>Custom dashboard + Flower</td>
<td>Real-time progress and worker health</td>
</tr>
<tr>
<td>Resumability</td>
<td>SCHEDULER_PERSIST=True</td>
<td>Crash-safe, restartable crawls</td>
</tr>
</tbody></table>
<hr />
<p><em>Published via <a href="https://zyvop.com/python-scraping-at-scale-distributed-crawling-across-multiple-machines-2026-kti42?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Four Ways a Refresh Token Request Fails — Only One Means Trouble]]></title><description><![CDATA[A refresh token exists for one reason: exchange itself for a new access token, once, and then stop being useful. Everything about a good implementation follows from taking "once" literally. This one d]]></description><link>https://blog.zyvop.com/four-ways-a-refresh-token-request-fails-only-one-means-trouble</link><guid isPermaLink="true">https://blog.zyvop.com/four-ways-a-refresh-token-request-fails-only-one-means-trouble</guid><category><![CDATA[#apisecurity]]></category><category><![CDATA[authentication]]></category><category><![CDATA[JWT]]></category><category><![CDATA[nestjs]]></category><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:48:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/67933cd1-8101-464a-a61a-66d01acd0b67.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A refresh token exists for one reason: exchange itself for a new access token, once, and then stop being useful. Everything about a good implementation follows from taking "once" literally. This one does — every refresh token is single-use, and grouped with every other token descended from the same login into a family. Calling <code>POST /auth/refresh</code> with a given token can fail four different ways, and three of them are just bookkeeping. The fourth is the one this post is actually about.</p>
<h2>One row per token, one family per login</h2>
<pre><code class="language-typescript">@Entity()
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Index()
  @Column()
  familyId: string;

  @Index({ unique: true })
  @Column()
  tokenHash: string;

  @Column({ default: false })
  used: boolean;

  @Column({ default: false })
  revoked: boolean;

  @Column({ type: 'timestamp' })
  expiresAt: Date;

  @CreateDateColumn()
  createdAt: Date;
}
</code></pre>
<p><code>familyId</code> is what makes "family" more than a metaphor — it's set once, at login, and then carried forward unchanged into every row that session's rotation produces afterward:</p>
<pre><code class="language-typescript">async login(@Body() dto: LoginDto) {
  const user = await this.authService.validatePassword(dto.email, dto.password);
  const accessToken = this.authService.issueAccessToken(user.id, user.email);
  const refresh = await this.refreshTokenService.issue(user.id); // no familyId = fresh session
  return { accessToken, refreshToken: refresh.rawToken };
}
</code></pre>
<p>Omitting <code>familyId</code> here is the signal that this is a brand new session rather than a rotation — <code>issue()</code> generates a fresh one. Every later call to <code>issue()</code> for this same chain, from inside <code>rotate()</code>, passes that same value back in instead. Two logins from the same user, at the same time, produce two completely independent families — there's nothing linking them beyond sharing a <code>userId</code>, which matters later.</p>
<h2>Three ordinary rejections</h2>
<p><strong>A token that was never real.</strong> Someone sends a string that doesn't hash to anything in the table.</p>
<pre><code class="language-typescript">if (!stored) {
  throw new UnauthorizedException('Invalid refresh token');
}
</code></pre>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/refresh -H "Content-Type: application/json" \
  -d '{"refreshToken":"not-a-real-token-at-all"}'
# -&gt; 401 {"message":"Invalid refresh token", ...}
</code></pre>
<p><strong>A token that aged out.</strong> Refresh tokens in this implementation live 7 days by default. Past that, they're just gone.</p>
<pre><code class="language-typescript">if (stored.expiresAt &lt; new Date()) {
  throw new UnauthorizedException('Refresh token has expired');
}
</code></pre>
<p>Confirmed directly rather than waiting a week: backdate a token's <code>expiresAt</code> in Postgres by a day, then try to use it —</p>
<pre><code class="language-json">{"message":"Refresh token has expired","error":"Unauthorized","statusCode":401}
</code></pre>
<p>— exactly the branch that's supposed to fire.</p>
<p><strong>A token whose session already ended.</strong> Logout, or (as covered below) a reuse event elsewhere in the same family, flips a <code>revoked</code> flag.</p>
<pre><code class="language-typescript">if (stored.revoked) {
  throw new UnauthorizedException('Refresh token has been revoked');
}
</code></pre>
<p>None of these three are interesting on their own — they're the normal outcomes of a token being wrong, old, or deliberately ended. The fourth rejection reason looks identical from the outside (same 401, same shape) but means something completely different underneath.</p>
<h2>The fourth: a token that's already been spent</h2>
<pre><code class="language-typescript">if (stored.used) {
  await this.revokeFamily(stored.familyId);
  throw new UnauthorizedException('Refresh token reuse detected; session revoked');
}

stored.used = true;
await this.refreshTokenRepository.save(stored);

return this.issue(stored.userId, stored.familyId);
</code></pre>
<p>Every successful refresh marks the token it consumed as <code>used</code> before issuing its replacement. A legitimate client only ever moves forward through that chain — it gets a new token and uses <em>that</em> one next time, never the old one again.</p>
<p>So if an already-<code>used</code> token shows up in a request, the client presenting it isn't following the chain. Either it's a genuine client retrying a request whose response got lost on the way back (a real possibility, not a hypothetical), or it's a second party holding a copy of a token the real owner has already moved past.</p>
<p>There's no way to tell those apart from inside this one request — both look exactly like "this exact token, again" — so both get treated as the same signal: something's wrong with this session, not just this token.</p>
<p>That's why the response isn't "reject this token and move on." It's <code>revokeFamily</code>, which doesn't touch just the token that got reused — it kills every unrevoked row sharing that <code>familyId</code>, including whichever token the legitimate client is currently holding as its "real" one.</p>
<p>Proving that actually happens, not just reading the code and assuming: rotate once (<code>RT1</code> → <code>RT2</code>), then try <code>RT1</code> again.</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/refresh -H "Content-Type: application/json" -d '{"refreshToken":"&lt;RT1&gt;"}'
# -&gt; 401 {"message":"Refresh token reuse detected; session revoked", ...}
</code></pre>
<p><code>RT1</code> failing is expected — it's already used. The real test is what happens to <code>RT2</code>, which was never itself reused, was legitimately issued, and — if the family weren't revoked — should still work fine:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/refresh -H "Content-Type: application/json" -d '{"refreshToken":"&lt;RT2&gt;"}'
# -&gt; 401 {"message":"Refresh token has been revoked", ...}
</code></pre>
<p><code>RT2</code> dies too, and the error message even confirms <em>why</em> — not "reuse detected" (that already happened, on <code>RT1</code>) but plain "revoked," which is exactly what a downstream consequence of someone else's reuse should look like from <code>RT2</code>'s own perspective. This is the actual security property, not the headline description of it: a compromise anywhere in the chain takes down the entire chain, including the part that was never touched.</p>
<p>The blast radius runs the other direction too, and it's worth confirming it actually stops where it should: a second user, <code>bob@example.com</code>, with his own completely separate family, refreshes without incident while all of the above is happening to alice's account. Nothing about <code>bob</code>'s session is touched — <code>revokeFamily</code> only ever operates on rows sharing the one <code>familyId</code> it was given, and <code>bob</code>'s family was never that one. The failure is total within a compromised family and entirely absent outside it.</p>
<h2>Logging out is the same mechanism, aimed on purpose</h2>
<p>Every revocation above happened as a side effect of something going wrong. Logout is the identical underlying operation — <code>revokeFamily</code> — called directly, when nothing is wrong at all:</p>
<pre><code class="language-typescript">async revokeFamilyByToken(rawToken: string): Promise&lt;void&gt; {
  const stored = await this.refreshTokenRepository.findOne({
    where: { tokenHash: this.hash(rawToken) },
  });
  if (stored) {
    await this.revokeFamily(stored.familyId);
  }
}
</code></pre>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/logout -H "Content-Type: application/json" \
  -d '{"refreshToken":"&lt;RT&gt;"}'
# -&gt; { "message": "Logged out" }

curl -X POST http://localhost:3000/auth/refresh -H "Content-Type: application/json" \
  -d '{"refreshToken":"&lt;RT&gt;"}'
# -&gt; 401 {"message":"Refresh token has been revoked", ...}
</code></pre>
<p>One question this raises immediately: does logging out on one device end every session, or just the one that called it? Worth an actual answer instead of a guess — log in twice for the same user (call them a phone session and a laptop session, two independent families), log out using only the phone's token, then try refreshing each:</p>
<pre><code class="language-bash">phone session, after logout: {"message":"Refresh token has been revoked", ...}
laptop session, untouched:   { "accessToken": "...", "refreshToken": "..." }   (succeeds normally)
</code></pre>
<p>Logging out ends the one family attached to the token that was actually presented. It has no way to reach a different family for the same user, because nothing about <code>revokeFamily</code> looks at <code>userId</code> at all — only <code>familyId</code>. Two logins are two unrelated chains that happen to belong to the same person, and ending one says nothing about the other.</p>
<h2>What doesn't die with the family</h2>
<p>Everything above is about the refresh token. The <em>access</em> token issued alongside <code>RT2</code> — call it <code>AT2</code> — is a separate, stateless JWT with its own 15-minute clock, and revoking a refresh family doesn't reach into that clock:</p>
<pre><code class="language-bash">curl http://localhost:3000/auth/me -H "Authorization: Bearer &lt;AT2&gt;"
# -&gt; 200 { "id": "...", "email": "alice@example.com" }
</code></pre>
<p>Issued <em>before</em> the reuse event above, and it still works <em>after</em> the whole family got revoked. That's not a gap in the implementation — it's the actual reason access tokens are kept short-lived in the first place. There's no database row for an access token to flip a <code>revoked</code> bit on; the only thing bounding how long a leaked one stays useful is its own expiry. Fifteen minutes is the real ceiling on "how bad is it if this specific token leaks," independent of anything the refresh layer detects or reacts to.</p>
<h2>Why the token hash is SHA-256, not bcrypt</h2>
<p>The password column in this same project uses <code>bcrypt</code>, and it should — passwords are short, human-chosen, and drawn from a relatively small effective space, so a hash that's deliberately <em>slow</em> is what makes brute-forcing a stolen hash impractical.</p>
<p>A refresh token is a different kind of secret: 40 bytes of <code>crypto.randomBytes</code>, 320 bits of pure randomness, with no human-guessable structure to exploit. Brute-forcing a hash of that is infeasible no matter how fast the hash function is, so <code>bcrypt</code>'s deliberate slowness buys nothing here — it just adds cost to every single refresh call for no security benefit. A plain, fast <code>sha256</code> is the correct tool once the secret's strength comes from entropy rather than from making guessing expensive.</p>
<h2>Trying this out, and what it doesn't cover</h2>
<pre><code class="language-bash">npm install &amp;&amp; cp .env.example .env
docker run --name refresh-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=refresh_demo -p 5432:5432 -d postgres:16
npm run start:dev
</code></pre>
<p><code>synchronize: true</code> handles table creation for this demo; swap it for real migrations before this touches production. Register and log in to get real values for the <code>&lt;RT1&gt;</code>/<code>&lt;RT2&gt;</code>/<code>&lt;AT2&gt;</code> placeholders used throughout above:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'

curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -&gt; { "accessToken": "&lt;AT1&gt;", "refreshToken": "&lt;RT1&gt;" }
</code></pre>
<p>Left out on purpose:</p>
<ul>
<li><p><strong>A cleanup job for expired and revoked rows.</strong> They'll otherwise just accumulate — the BullMQ patterns from elsewhere in this series are a natural fit for sweeping them out.</p>
</li>
<li><p><strong>Rate limiting on</strong> <code>/auth/login</code> <strong>and</strong> <code>/auth/refresh</code><strong>.</strong> Both are obvious targets, and the earlier rate-limiting post in this series covers them directly.</p>
</li>
<li><p><strong>A grace period for the "legitimate retry" half of the reuse ambiguity.</strong> Some production systems let a just-rotated token keep working for a few seconds, specifically to absorb a lost-response retry before treating reuse as a hard signal. This implementation takes the strict stance instead — simpler to reason about, simpler to verify — which is a deliberate tradeoff, not an oversight.</p>
</li>
</ul>
<p>The full project, including the parts of this walkthrough not reproduced above, is in the <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/refresh-token-rotation-demo">refresh-token-rotation-demo</a> repository next to this post.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/four-ways-a-refresh-token-request-fails-only-one-means-trouble-ozezt?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Build a URL Shortener With Click Analytics in Node.js]]></title><description><![CDATA[Bit.ly and short.io are fine until you want to know which country your clicks are coming from, which referrer is driving traffic, and whether that campaign from last Tuesday is still converting. At th]]></description><link>https://blog.zyvop.com/build-a-url-shortener-with-click-analytics-in-nodejs</link><guid isPermaLink="true">https://blog.zyvop.com/build-a-url-shortener-with-click-analytics-in-nodejs</guid><category><![CDATA[Ipgeolocation]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Security]]></category><category><![CDATA[SQLite click tracking]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:47:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/51129d9e-57f9-4521-900f-d67495093875.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://bitly.com/">Bit.ly</a> and <a href="https://short.io/">short.io</a> are fine until you want to know which country your clicks are coming from, which referrer is driving traffic, and whether that campaign from last Tuesday is still converting. At that point, you're either paying for a premium plan or wishing you'd just built it yourself.</p>
<p>This post builds a complete URL shortener with analytics — short code generation, SQLite storage, IP geolocation, referrer tracking, and a dashboard showing daily click trends, browser breakdown, and country distribution.</p>
<p>No external analytics service, no database server to spin up. The whole thing runs on SQLite and ships as a single Node.js process.</p>
<p>Source code: <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener">https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener</a></p>
<hr />
<h2>Stack and setup</h2>
<pre><code>Express 5 · SQLite (better-sqlite3) · geoip-lite · ua-parser-js · nanoid
</code></pre>
<pre><code class="language-bash">git clone [YOUR_GITHUB_REPO_URL]
cd url-shortener
npm install
npm start
</code></pre>
<p>Open <code>http://localhost:3000</code>. The database creates itself in <code>data/links.db</code> on first run.</p>
<p>One environment variable is required before clicks will be tracked — <code>HASH_SALT</code>, used to hash IP addresses before storing them. Generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>Add it to <code>.env</code>. Without it, the server starts but logs a warning and skips click recording on every redirect.</p>
<hr />
<h2>Short code generation</h2>
<p>The first decision: how to make the codes. Random is the right choice over sequential integers — sequential IDs are trivially enumerable, someone can increment from 1 and hit every link ever created.</p>
<p>Random codes with a good alphabet make that infeasible.</p>
<p>The alphabet deliberately excludes characters that look alike:</p>
<pre><code class="language-js">// src/lib/shortcode.js
import { customAlphabet } from "nanoid";

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
const CODE_LENGTH = 7;
const generate = customAlphabet(ALPHABET, CODE_LENGTH);

export function generateCode(db, existsFn, maxAttempts = 5) {
  for (let i = 0; i &lt; maxAttempts; i++) {
    const code = generate();
    if (!existsFn(db, code)) return code;
  }
  throw new Error("Failed to generate a unique code after multiple attempts");
}
</code></pre>
<p>No <code>0</code>, <code>O</code>, <code>I</code>, <code>i</code>, <code>l</code>, <code>o</code>, or <code>1</code> — seven characters that look like each other on printed paper or low-res screens. With 55 characters and 7 positions that's <code>55^7 ≈ 1.52 trillion</code> possible codes. Collision handling is implemented correctly anyway (retry up to 5 times), but with that address space and any realistic link volume, it will never be needed.</p>
<hr />
<h2>The database</h2>
<p>SQLite handles everything: URL storage, click records, and all the analytics queries. No Postgres, no Redis, no infrastructure to run. <code>better-sqlite3</code> is synchronous, which means no connection pool to manage and no async/await ceremony around queries.</p>
<p>Two tables:</p>
<pre><code class="language-sql">CREATE TABLE links (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  code       TEXT NOT NULL UNIQUE,
  url        TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);

CREATE TABLE clicks (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  link_id    INTEGER NOT NULL REFERENCES links(id),
  clicked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
  country    TEXT,
  referrer   TEXT,
  browser    TEXT,
  os         TEXT,
  ip_hash    TEXT
);
</code></pre>
<p>The analytics queries (top countries, referrers, daily trend) all run against the <code>clicks</code> table with a <code>JOIN</code> on <code>links</code>. The most complex one is the daily trend:</p>
<pre><code class="language-js">db.prepare(`
  SELECT
    strftime('%Y-%m-%d', clicked_at) AS date,
    COUNT(*) AS clicks
  FROM clicks WHERE link_id = ?
  GROUP BY date ORDER BY date DESC LIMIT 30
`).all(link.id);
</code></pre>
<p>SQLite's <code>strftime</code> handles the date bucketing natively. No date library needed.</p>
<p>One gotcha with the database module: <code>better-sqlite3</code> is synchronous and stateful, so the module caches the connection after the first call. Tests need isolated in-memory databases, not the cached production one. The fix is simple — never cache <code>:memory:</code> connections:</p>
<pre><code class="language-js">export function getDb(dbPath = DB_PATH) {
  if (dbPath !== ":memory:" &amp;&amp; _db) return _db;
  const db = new Database(dbPath);
  // ... schema setup ...
  if (dbPath !== ":memory:") _db = db;
  return db;
}
</code></pre>
<p>Each test gets its own fresh in-memory database. Nothing bleeds between test cases.</p>
<hr />
<h2>Recording clicks without slowing down redirects</h2>
<p>The redirect handler records the click and then sends the 301. <code>better-sqlite3</code> is synchronous — it blocks the Node.js event loop during the write — but WAL mode means it's appending to the write-ahead log file rather than syncing the main database.</p>
<p>A single INSERT completes in microseconds in practice. The try/catch ensures analytics failures never break a redirect:</p>
<pre><code class="language-js">// src/routes/redirect.js
const CODE_RE = /^[A-Za-z0-9]{4,10}$/;

router.get("/:code", (req, res) =&gt; {
  const { code } = req.params;
  if (!CODE_RE.test(code)) return res.status(404).send("Link not found.");

  const link = getLinkByCode(db, code);
  if (!link) return res.status(404).send("Link not found.");

  try {
    const clickData = parseClickData(req);
    insertClick(db, link.id, clickData);
  } catch (err) {
    console.error("[redirect] analytics error:", err.message);
  }

  res.redirect(301, link.url);
});
</code></pre>
<p>The <code>/:code</code> route must be registered last in <code>server.js</code>. It matches any single-segment path (including <code>/healthz</code>), so any named routes registered after it will never be reached. Express matches in registration order, not specificity order.</p>
<p>One other decision worth noting: <code>301</code> vs <code>302</code>. A 301 (permanent redirect) tells browsers and search engines to cache the destination — follow the link once and the browser may never hit the shortener again.</p>
<p>That means if you later change the destination URL, existing users won't see the change. For a personal shortener that's usually fine; use <code>302</code> if you need updatable destinations.</p>
<p>The <code>POST /api/shorten</code> endpoint is rate-limited to 20 requests per hour per IP. Redirect and stats endpoints are not limited — a popular link shouldn't throttle its own clicks.</p>
<hr />
<h2>What gets tracked and how</h2>
<p>Three pieces of data come in on every click: the IP address, the <code>User-Agent</code> header, and the <code>Referer</code> header.</p>
<pre><code class="language-js">// src/lib/analytics.js
export function parseClickData(req) {
  const ip = getClientIp(req);
  const ua = req.headers["user-agent"] ?? "";
  const rawReferrer = req.headers["referer"] || req.headers["referrer"] || null;

  const salt = process.env.HASH_SALT;
  if (!salt) throw new Error("HASH_SALT is not set. Add it to your .env file.");
  const ipHash = crypto.createHash("sha256").update(ip + salt).digest("hex").slice(0, 16);
  const geo = ip ? geoip.lookup(ip) : null;
  const country = geo?.country ?? null;

  const parsed = UAParser(ua);
  const browser = parsed.browser?.name ?? null;
  const os = parsed.os?.name ?? null;

  let referrer = null;
  if (rawReferrer) {
    try {
      referrer = new URL(rawReferrer).hostname;
    } catch {
      referrer = rawReferrer.slice(0, 100);
    }
  }

  return { country, referrer, browser, os, ipHash };
}
</code></pre>
<p>The IP gets hashed before storage. Raw IPs are PII — storing them without justification is a compliance headache in most jurisdictions.</p>
<p>A SHA-256 hash of the IP (keyed with a secret from your environment) lets you count unique visitors reliably without keeping the raw address. <code>geoip-lite</code> does the country lookup from a bundled local database, so there's no external API call.</p>
<p>Referrers get normalized to hostname only. <code>https://google.com/search?q=example</code> becomes <code>google.com</code>. The full URL isn't useful for the dashboard, and storing full search queries you didn't ask for is the kind of thing that causes problems later.</p>
<hr />
<h2>The dashboard</h2>
<p>The dashboard is a single HTML file that calls the API. No framework:</p>
<ul>
<li><p><code>GET /api/links</code> — all shortened links with total click counts</p>
</li>
<li><p><code>GET /api/stats/:code</code> — full breakdown for one link (by country, referrer, browser, daily trend)</p>
</li>
</ul>
<p>The daily trend renders as a bar chart built from raw <code>div</code> elements. Bar height is a percentage of the maximum day's count, so no chart library dependency.</p>
<p>Clicking "Stats" on any row loads that link's analytics inline below the table. The stat panel shows total clicks, unique visitors (by hashed IP), and three bar charts. Country shows the top 10 countries, referrer shows where traffic came from, browser breaks down the client distribution.</p>
<hr />
<h2>Trying it with curl</h2>
<p>Make sure <code>HASH_SALT</code> is set in <code>.env</code> before running these — without it the server starts and redirects work, but clicks won't be recorded and stats will show zero.</p>
<p><strong>Shorten a URL:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://zyvop.com/building-a-production-ai-agent-in-node-js-tool-calling-the-react-loop-and-error-handling-zzftm}'
</code></pre>
<pre><code class="language-json">{
  "code": "P8WbGd7",
  "shortUrl": "http://localhost:3000/P8WbGd7",
  "url": "https://zyvop.com/building-a-production-ai-agent-in-nodejs"
}
</code></pre>
<p><strong>Follow the redirect:</strong></p>
<pre><code class="language-bash">curl -I http://localhost:3000/P8WbGd7
</code></pre>
<pre><code class="language-typescript">HTTP/1.1 301 Moved Permanently
Location: https://zyvop.com/building-a-production-ai-agent-in-nodejs
</code></pre>
<p><strong>Pull the stats:</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/stats/P8WbGd7
</code></pre>
<pre><code class="language-json">{
  "link": { "code": "P8WbGd7", "url": "https://zyvop.com/...", "created_at": "2026-07-14T06:00:00Z" },
  "totalClicks": 3,
  "uniqueVisitors": 1,
  "byCountry": [{ "country": "US", "clicks": 3 }],
  "byReferrer": [{ "referrer": "google.com", "clicks": 2 }, { "referrer": "Direct", "clicks": 1 }],
  "byBrowser": [{ "browser": "Chrome", "clicks": 3 }],
  "dailyTrend": [{ "date": "2026-07-14", "clicks": 3 }]
}
</code></pre>
<p><code>"Direct"</code> in <code>byReferrer</code> is what <code>COALESCE(referrer, 'Direct')</code> returns for clicks that arrived without a <code>Referer</code> header — typed directly into the browser bar, opened from a native app, or clicked from an email client that strips referrers.</p>
<p><strong>Invalid URL:</strong></p>
<pre><code class="language-bash">curl -s -w " [%{http_code}]" -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "ftp://notallowed"}'
</code></pre>
<pre><code class="language-css">{"error":"url must be a valid http or https URL."} [400]
</code></pre>
<p><strong>Unknown code:</strong></p>
<pre><code class="language-bash">curl -I http://localhost:3000/ZZZZZZZ
</code></pre>
<pre><code>HTTP/1.1 404 Not Found
</code></pre>
<hr />
<h2>Tests</h2>
<pre><code class="language-bash">npm test
</code></pre>
<pre><code class="language-python"># tests 24
# pass  24
# fail   0
</code></pre>
<p>24 tests across three suites. The shortcode suite verifies the alphabet, the collision retry logic, and that it throws correctly when retries are exhausted.</p>
<p>The analytics suite tests IP hashing, country lookup, referrer normalization, and user-agent parsing — including the privacy property that raw IPs don't appear in the stored hash. The database suite runs every query against an in-memory SQLite instance: inserts, retrieval, unique visitor counting, daily trend grouping, and the unique constraint on codes.</p>
<hr />
<h2>Taking it further</h2>
<p>The setup above works for personal use or a small team. A few things to add before opening it to the public:</p>
<ul>
<li><p><strong>Auth on the shorten endpoint</strong> — right now anyone who can reach the server can create links. A simple API key check is enough for most cases.</p>
</li>
<li><p><strong>Custom slugs</strong> — let users specify <code>POST /api/shorten</code> with an optional <code>customCode</code> field; validate it against the same alphabet and check for conflicts before inserting.</p>
</li>
<li><p><strong>Click buffering</strong> — if redirect volume gets high, the synchronous SQLite write on every click becomes the bottleneck. Buffer clicks in memory and flush in batches of 100 or every 5 seconds.</p>
</li>
<li><p><strong>Link expiry</strong> — add an <code>expires_at</code> column to <code>links</code> and check it in the redirect handler.</p>
</li>
</ul>
<p>Get the code: <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener">https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener</a></p>
<hr />
<p><em>Published via <a href="https://zyvop.com/build-a-url-shortener-with-click-analytics-in-node-js-ijtv8?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Scraping Social Media Data with Python: X (Twitter), Reddit & Instagram (2026)]]></title><description><![CDATA[Over 500 million posts are published on X (formerly Twitter) every single day. Reddit hosts more than 100,000 active communities discussing everything from machine learning to local weather. Instagram]]></description><link>https://blog.zyvop.com/scraping-social-media-data-with-python-x-twitter-reddit-instagram-2026</link><guid isPermaLink="true">https://blog.zyvop.com/scraping-social-media-data-with-python-x-twitter-reddit-instagram-2026</guid><category><![CDATA[instagramdatascrapingpython]]></category><category><![CDATA[Scraping]]></category><category><![CDATA[scraperedditpythontutorial]]></category><category><![CDATA[scrapetwitterxpython2026]]></category><category><![CDATA[sentiment ]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:46:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/59345efb-1a2e-4e15-82ce-5f00f3a037af.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Over 500 million posts are published on X (formerly Twitter) every single day. Reddit hosts more than 100,000 active communities discussing everything from machine learning to local weather. Instagram's 2 billion users generate a continuous stream of product opinions, lifestyle signals, and trend indicators.</p>
<p>Social media data is the closest thing the internet has to real-time public opinion. It powers:</p>
<ul>
<li><p><strong>Sentiment analysis</strong> — What do people think about your product, brand, or competitor right now?</p>
</li>
<li><p><strong>Trend detection</strong> — What topics are accelerating before they go mainstream?</p>
</li>
<li><p><strong>Academic research</strong> — Studying misinformation, political discourse, crisis communication</p>
</li>
<li><p><strong>Market intelligence</strong> — Tracking competitor mentions, industry conversations</p>
</li>
<li><p><strong>AI training datasets</strong> — Social text is gold for fine-tuning conversational models</p>
</li>
</ul>
<p>The challenge in 2026: platforms have made scraping harder than ever. The official X API now starts at $100/month and the free tier is almost useless for research. Instagram aggressively blocks headless browsers. Reddit has rate-limited its API severely after the 2023 controversy.</p>
<p>This guide gives you working approaches for all three platforms — from DIY Python scrapers to managed tools — with honest assessments of what each method can and cannot do.</p>
<hr />
<h2>The 2026 Reality Check: What's Still Scrapable</h2>
<p>Before writing a single line of code, here's the honest state of social media scraping in 2026:</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Public Posts</th>
<th>Profile Data</th>
<th>Followers</th>
<th>DMs</th>
<th>Difficulty</th>
</tr>
</thead>
<tbody><tr>
<td>X / Twitter</td>
<td>✅ (with effort)</td>
<td>✅</td>
<td>✅</td>
<td>❌</td>
<td>Hard</td>
</tr>
<tr>
<td>Reddit</td>
<td>✅ (via API/PRAW)</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
<td>Easy–Medium</td>
</tr>
<tr>
<td>Instagram</td>
<td>⚠️ Public only</td>
<td>⚠️ Public only</td>
<td>❌</td>
<td>❌</td>
<td>Very Hard</td>
</tr>
<tr>
<td>TikTok</td>
<td>✅ (via mobile API)</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
<td>Hard</td>
</tr>
<tr>
<td>LinkedIn</td>
<td>⚠️ (see Blog 02)</td>
<td>⚠️ (see Blog 02)</td>
<td>❌</td>
<td>❌</td>
<td>Hard</td>
</tr>
</tbody></table>
<p>Key principles:</p>
<ul>
<li><p><strong>Never scrape private data, DMs, or anything behind a login you don't own</strong></p>
</li>
<li><p><strong>Always respect robots.txt and Terms of Service for your use case</strong></p>
</li>
<li><p><strong>For academic or commercial research, official APIs or licensed providers are the safer path</strong></p>
</li>
</ul>
<hr />
<h2>Part 1: Scraping X (Twitter) in 2026</h2>
<h3>The Landscape</h3>
<p>X's public guest API was effectively removed in 2023. Almost every endpoint now requires a logged-in session and a CSRF token. The internal GraphQL API that underpins X's own web interface is the main DIY scraping target — but its endpoint identifiers change regularly.</p>
<p>Three working approaches exist in 2026:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Cost</th>
<th>Volume</th>
<th>Reliability</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>X API v2 (official)</td>
<td>$100+/month</td>
<td>Limited</td>
<td>High</td>
<td>Authorised developers</td>
</tr>
<tr>
<td>Playwright + session</td>
<td>Free</td>
<td>Low-medium</td>
<td>Medium</td>
<td>Research, personal use</td>
</tr>
<tr>
<td>ScrapeGraphAI</td>
<td>Paid per req</td>
<td>High</td>
<td>High</td>
<td>Production</td>
</tr>
</tbody></table>
<h3>Method 1: X API v2 (Official)</h3>
<p>For authorised projects, the official API is always the right choice. The free tier gives 500k tweets/month read access:</p>
<pre><code class="language-bash">pip install tweepy
</code></pre>
<pre><code class="language-python">import tweepy
import pandas as pd
from datetime import datetime, timezone, timedelta

# Get credentials at developer.twitter.com
client = tweepy.Client(bearer_token="YOUR_BEARER_TOKEN")

def search_recent_tweets(
    query: str,
    max_results: int = 100,
    days_back: int = 7
) -&gt; pd.DataFrame:
    """
    Search tweets from the last 7 days using X API v2.

    Args:
        query: Search query. Supports operators like:
               - AND/OR: "python scraping OR web crawling"
               - Exact: '"machine learning" tutorial'
               - Exclude: "python -is:retweet"
               - Language: "python lang:en"
               - Has media: "python has:images"
    """
    start_time = datetime.now(timezone.utc) - timedelta(days=days_back)

    # Append filters to reduce noise
    full_query = f"{query} -is:retweet lang:en"

    tweets_data = []
    paginator = tweepy.Paginator(
        client.search_recent_tweets,
        query=full_query,
        tweet_fields=[
            "created_at", "public_metrics", "author_id",
            "lang", "context_annotations", "entities"
        ],
        user_fields=["name", "username", "public_metrics", "verified"],
        expansions=["author_id"],
        start_time=start_time,
        max_results=min(100, max_results),
    )

    users_map = {}

    for response in paginator:
        if not response.data:
            break

        # Build user lookup from includes
        if response.includes and "users" in response.includes:
            for user in response.includes["users"]:
                users_map[user.id] = user

        for tweet in response.data:
            user = users_map.get(tweet.author_id)
            metrics = tweet.public_metrics or {}

            tweets_data.append({
                "tweet_id":       str(tweet.id),
                "text":           tweet.text,
                "created_at":     tweet.created_at,
                "author_id":      str(tweet.author_id),
                "username":       user.username if user else None,
                "name":           user.name if user else None,
                "followers":      user.public_metrics.get("followers_count") if user else None,
                "retweets":       metrics.get("retweet_count", 0),
                "likes":          metrics.get("like_count", 0),
                "replies":        metrics.get("reply_count", 0),
                "quotes":         metrics.get("quote_count", 0),
                "impressions":    metrics.get("impression_count", 0),
                "url":            f"https://x.com/{user.username if user else 'i'}/status/{tweet.id}",
            })

        if len(tweets_data) &gt;= max_results:
            break

    df = pd.DataFrame(tweets_data)
    print(f"Collected {len(df)} tweets for query: '{query}'")
    return df

# Example: track brand mentions
df = search_recent_tweets(
    query="python web scraping 2026",
    max_results=200,
    days_back=7
)
df.to_csv("tweets.csv", index=False)
print(df[["username", "text", "likes", "retweets"]].head(10))
</code></pre>
<h3>Method 2: Playwright-Based X Scraper (No API Key)</h3>
<p>For personal research without API access, Playwright can scrape public search results and profiles using a saved session — exactly as described for LinkedIn in Blog 02:</p>
<pre><code class="language-python">import asyncio
import random
import json
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async

async def save_x_session():
    """Log in to X manually once and save cookies. Run this first."""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120"
        )
        page = await context.new_page()
        await page.goto("https://x.com/login")

        print("Log in manually, then press Enter...")
        input()

        cookies = await context.cookies()
        with open("x_cookies.json", "w") as f:
            json.dump(cookies, f)
        print(f"Saved {len(cookies)} cookies.")
        await browser.close()

async def scrape_x_search(query: str, scroll_times: int = 10) -&gt; list[dict]:
    """
    Scrape X search results using a saved session.
    Returns a list of tweet dicts.
    """
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--disable-blink-features=AutomationControlled"]
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120",
            viewport={"width": 1280, "height": 900}
        )

        # Load saved session
        with open("x_cookies.json") as f:
            await context.add_cookies(json.load(f))

        page = await context.new_page()
        await stealth_async(page)

        # Navigate to search
        encoded = query.replace(" ", "%20")
        await page.goto(
            f"https://x.com/search?q={encoded}&amp;src=typed_query&amp;f=top",
            wait_until="domcontentloaded"
        )
        await asyncio.sleep(random.uniform(2, 4))

        tweets = []
        seen_ids = set()

        for scroll in range(scroll_times):
            # Extract tweet data from the current viewport
            tweet_articles = await page.query_selector_all("article[data-testid='tweet']")

            for article in tweet_articles:
                try:
                    # Get tweet text
                    text_el = await article.query_selector("[data-testid='tweetText']")
                    text = await text_el.inner_text() if text_el else None

                    # Username
                    user_el = await article.query_selector("[data-testid='User-Name'] a")
                    href = await user_el.get_attribute("href") if user_el else ""
                    username = href.strip("/").split("/")[-1] if href else None

                    # Display name
                    name_els = await article.query_selector_all(
                        "[data-testid='User-Name'] span"
                    )
                    display_name = None
                    for el in name_els:
                        txt = await el.inner_text()
                        if txt and not txt.startswith("@"):
                            display_name = txt
                            break

                    # Engagement stats
                    async def get_stat(testid):
                        el = await article.query_selector(f"[data-testid='{testid}']")
                        if el:
                            txt = await el.inner_text()
                            return txt.strip() or "0"
                        return "0"

                    likes    = await get_stat("like")
                    replies  = await get_stat("reply")
                    retweets = await get_stat("retweet")

                    # Time
                    time_el = await article.query_selector("time")
                    posted_at = await time_el.get_attribute("datetime") if time_el else None

                    # Unique ID to deduplicate
                    tweet_id = f"{username}_{posted_at}"
                    if tweet_id in seen_ids or not text:
                        continue
                    seen_ids.add(tweet_id)

                    tweets.append({
                        "username":     username,
                        "display_name": display_name,
                        "text":         text,
                        "likes":        likes,
                        "replies":      replies,
                        "retweets":     retweets,
                        "posted_at":    posted_at,
                    })
                except Exception:
                    continue

            # Scroll down for more tweets
            await page.evaluate("window.scrollBy(0, window.innerHeight * 1.5)")
            await asyncio.sleep(random.uniform(1.5, 3.0))
            print(f"  Scroll {scroll+1}/{scroll_times} — {len(tweets)} tweets collected")

        await browser.close()

    print(f"\nTotal unique tweets: {len(tweets)}")
    return tweets

# Run
tweets = asyncio.run(scrape_x_search("python scraping 2026", scroll_times=8))
df = pd.DataFrame(tweets)
df.to_csv("x_search_results.csv", index=False)
</code></pre>
<hr />
<h2>Part 2: Reddit Scraping with PRAW</h2>
<p>Reddit is the most developer-friendly major social platform for data collection. Its official Python wrapper PRAW (Python Reddit API Wrapper) is free, well-documented, and gives access to posts, comments, user profiles, and subreddit data at no cost.</p>
<pre><code class="language-bash">pip install praw pandas
</code></pre>
<p>Register a Reddit app at <a href="https://reddit.com/prefs/apps">reddit.com/prefs/apps</a> (takes 2 minutes, free) to get your client ID and secret.</p>
<h3>Scraping subreddit posts</h3>
<pre><code class="language-python">import praw
import pandas as pd
from datetime import datetime, timezone

reddit = praw.Reddit(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET",
    user_agent="PythonResearchBot/1.0 by u/your_username",
)

def scrape_subreddit(
    subreddit_name: str,
    sort: str = "hot",         # "hot", "new", "top", "rising"
    time_filter: str = "week", # "hour", "day", "week", "month", "year", "all"
    limit: int = 500
) -&gt; pd.DataFrame:
    """
    Scrape posts from a subreddit.

    Best for: topic research, trend monitoring, content analysis.
    """
    sub = reddit.subreddit(subreddit_name)

    # Choose sort method
    if sort == "hot":
        posts_gen = sub.hot(limit=limit)
    elif sort == "new":
        posts_gen = sub.new(limit=limit)
    elif sort == "top":
        posts_gen = sub.top(time_filter=time_filter, limit=limit)
    elif sort == "rising":
        posts_gen = sub.rising(limit=limit)
    else:
        posts_gen = sub.hot(limit=limit)

    records = []
    for post in posts_gen:
        records.append({
            "post_id":       post.id,
            "title":         post.title,
            "text":          post.selftext[:500] if post.selftext else None,
            "author":        str(post.author) if post.author else "[deleted]",
            "score":         post.score,
            "upvote_ratio":  post.upvote_ratio,
            "num_comments":  post.num_comments,
            "url":           post.url,
            "permalink":     f"https://reddit.com{post.permalink}",
            "flair":         post.link_flair_text,
            "is_self":       post.is_self,
            "created_utc":   datetime.fromtimestamp(post.created_utc, tz=timezone.utc).isoformat(),
            "subreddit":     subreddit_name,
        })

    df = pd.DataFrame(records)
    print(f"Scraped {len(df)} posts from r/{subreddit_name}")
    return df

# Example: research Python discussions
df = scrape_subreddit("learnpython", sort="top", time_filter="month", limit=500)
df.to_csv("reddit_learnpython.csv", index=False)
print(df[["title", "score", "num_comments"]].head(10))
</code></pre>
<h3>Scraping post comments (deep dive)</h3>
<pre><code class="language-python">def scrape_post_comments(
    post_url: str,
    max_comments: int = 200,
    include_replies: bool = False
) -&gt; pd.DataFrame:
    """
    Scrape all comments from a Reddit post.
    Useful for sentiment analysis, topic deep-dives, building datasets.
    """
    submission = reddit.submission(url=post_url)

    # Replace "MoreComments" objects to get all comments
    submission.comments.replace_more(limit=0)

    all_comments = submission.comments.list() if include_replies else submission.comments

    records = []
    for comment in list(all_comments)[:max_comments]:
        if not hasattr(comment, "body"):
            continue
        records.append({
            "comment_id":    comment.id,
            "author":        str(comment.author) if comment.author else "[deleted]",
            "body":          comment.body,
            "score":         comment.score,
            "depth":         comment.depth,
            "created_utc":   datetime.fromtimestamp(
                                 comment.created_utc, tz=timezone.utc
                             ).isoformat(),
            "is_op":         comment.is_submitter,
            "awards":        comment.total_awards_received,
        })

    return pd.DataFrame(records)

# Example: deep dive on a specific post
comments_df = scrape_post_comments(
    "https://www.reddit.com/r/MachineLearning/comments/example/",
    max_comments=300
)
print(f"Top comment: {comments_df.sort_values('score', ascending=False).iloc[0]['body'][:200]}")
</code></pre>
<h3>Multi-subreddit keyword monitoring</h3>
<pre><code class="language-python">def monitor_keyword_across_subreddits(
    keyword: str,
    subreddits: list[str],
    limit_per_sub: int = 100
) -&gt; pd.DataFrame:
    """
    Search for a keyword across multiple subreddits.
    Great for competitive intelligence and brand monitoring.
    """
    all_posts = []

    for sub_name in subreddits:
        print(f"Searching r/{sub_name} for '{keyword}'...")
        try:
            sub = reddit.subreddit(sub_name)
            for post in sub.search(keyword, sort="new", limit=limit_per_sub):
                all_posts.append({
                    "subreddit":    sub_name,
                    "title":        post.title,
                    "text":         post.selftext[:300] if post.selftext else None,
                    "author":       str(post.author) if post.author else "[deleted]",
                    "score":        post.score,
                    "comments":     post.num_comments,
                    "permalink":    f"https://reddit.com{post.permalink}",
                    "created_utc":  datetime.fromtimestamp(
                                        post.created_utc, tz=timezone.utc
                                    ).isoformat(),
                })
        except Exception as e:
            print(f"  Error on r/{sub_name}: {e}")

    df = pd.DataFrame(all_posts)

    # Summary by subreddit
    summary = df.groupby("subreddit").agg(
        posts=("title", "count"),
        avg_score=("score", "mean"),
        total_comments=("comments", "sum")
    ).sort_values("posts", ascending=False)

    print(f"\n── Results for '{keyword}' ──")
    print(summary.to_string())

    return df

# Track "Python scraping" across tech subreddits
df = monitor_keyword_across_subreddits(
    keyword="python scraping",
    subreddits=["Python", "learnpython", "webdev", "datascience", "MachineLearning"],
    limit_per_sub=50
)
df.to_csv("reddit_keyword_monitor.csv", index=False)
</code></pre>
<hr />
<h2>Part 3: Instagram Scraping (Public Profiles Only)</h2>
<p>Instagram is the most aggressively protected major platform in 2026. The public guest API was removed years ago. Almost all data requires a logged-in session. The GraphQL API underlying Instagram's web interface changes identifiers constantly.</p>
<p>This section covers only public profile and hashtag data — the bare minimum available without login — and the Playwright approach for logged-in access to your own account's data.</p>
<h3>Scraping public profile metadata</h3>
<pre><code class="language-python">import httpx
import json
import asyncio

async def get_instagram_profile(username: str) -&gt; dict | None:
    """
    Fetch public profile data for an Instagram username.
    Uses the semi-public shared data endpoint.
    Only works for public accounts.
    """
    url = f"https://www.instagram.com/{username}/?__a=1&amp;__d=dis"

    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 Chrome/120 Safari/537.36"
        ),
        "Accept": "*/*",
        "Referer": "https://www.instagram.com/",
        "X-IG-App-ID": "936619743392459",  # Instagram Web app ID
    }

    async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
        try:
            r = await client.get(url, timeout=15)
            if r.status_code == 200:
                data = r.json()
                user = data.get("graphql", {}).get("user", {})
                return {
                    "username":       user.get("username"),
                    "full_name":      user.get("full_name"),
                    "biography":      user.get("biography"),
                    "followers":      user.get("edge_followed_by", {}).get("count"),
                    "following":      user.get("edge_follow", {}).get("count"),
                    "posts":          user.get("edge_owner_to_timeline_media", {}).get("count"),
                    "is_verified":    user.get("is_verified"),
                    "is_business":    user.get("is_business_account"),
                    "profile_url":    f"https://www.instagram.com/{username}/",
                }
        except Exception as e:
            print(f"Error fetching @{username}: {e}")
    return None

# Batch scrape public profiles
async def batch_profile_scrape(usernames: list[str]) -&gt; pd.DataFrame:
    results = []
    for username in usernames:
        print(f"Fetching @{username}...")
        profile = await get_instagram_profile(username)
        if profile:
            results.append(profile)
        await asyncio.sleep(random.uniform(2, 5))
    return pd.DataFrame(results)

profiles_df = asyncio.run(batch_profile_scrape([
    "natgeo", "nasa", "python.learning"
]))
print(profiles_df[["username", "followers", "posts", "is_verified"]])
</code></pre>
<h3>Playwright-based Instagram scraper (logged-in session)</h3>
<p>For your own account's data or influencer research on public accounts:</p>
<pre><code class="language-python">import asyncio, json, random
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async

async def save_instagram_session():
    """Log in manually and save session cookies. Run once."""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
                       "AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1",
            viewport={"width": 390, "height": 844},
            is_mobile=True,
        )
        page = await context.new_page()
        await page.goto("https://www.instagram.com/accounts/login/")
        print("Log in manually, then press Enter...")
        input()
        cookies = await context.cookies()
        with open("ig_cookies.json", "w") as f:
            json.dump(cookies, f)
        print("Session saved.")
        await browser.close()

async def scrape_instagram_posts(username: str, max_posts: int = 30) -&gt; list[dict]:
    """
    Scrape recent posts from a public Instagram profile.
    Requires a saved session from save_instagram_session().
    """
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-blink-features=AutomationControlled"]
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120",
            viewport={"width": 1280, "height": 900}
        )
        with open("ig_cookies.json") as f:
            await context.add_cookies(json.load(f))

        page = await context.new_page()
        await stealth_async(page)

        await page.goto(
            f"https://www.instagram.com/{username}/",
            wait_until="domcontentloaded"
        )
        await asyncio.sleep(random.uniform(2, 4))

        posts = []
        # Scroll to load posts
        for _ in range(max_posts // 12 + 1):
            post_links = await page.query_selector_all("article a[href*='/p/']")
            for link in post_links:
                href = await link.get_attribute("href")
                if href and href not in [p.get("href") for p in posts]:
                    posts.append({"href": href, "username": username})
            await page.evaluate("window.scrollBy(0, window.innerHeight)")
            await asyncio.sleep(random.uniform(1, 2.5))

        # Deduplicate and limit
        seen = set()
        unique_posts = []
        for p in posts:
            if p["href"] not in seen:
                seen.add(p["href"])
                unique_posts.append(p)
        posts = unique_posts[:max_posts]

        await browser.close()

    print(f"Found {len(posts)} posts for @{username}")
    return posts
</code></pre>
<hr />
<h2>Part 4: Sentiment Analysis on Collected Data</h2>
<p>Once you've collected social media data, analysing sentiment turns raw text into actionable signals. The <code>transformers</code> library from HuggingFace provides excellent pre-trained sentiment models:</p>
<pre><code class="language-bash">pip install transformers torch
</code></pre>
<pre><code class="language-python">from transformers import pipeline
import pandas as pd

# Load a pre-trained sentiment model (downloads ~67MB on first run)
# "cardiffnlp/twitter-roberta-base-sentiment-latest" is trained on tweets
sentiment_pipeline = pipeline(
    "sentiment-analysis",
    model="cardiffnlp/twitter-roberta-base-sentiment-latest",
    tokenizer="cardiffnlp/twitter-roberta-base-sentiment-latest",
    max_length=512,
    truncation=True,
)

LABEL_MAP = {
    "LABEL_0": "negative",
    "LABEL_1": "neutral",
    "LABEL_2": "positive",
}

def analyse_sentiment_batch(texts: list[str], batch_size: int = 32) -&gt; list[dict]:
    """
    Run sentiment analysis on a list of social media texts.
    Returns list of {label, score} dicts.
    """
    results = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        # Clean texts — remove URLs and excessive whitespace
        cleaned = [
            " ".join(word for word in t.split() if not word.startswith("http"))[:512]
            for t in batch
        ]
        preds = sentiment_pipeline(cleaned)
        for pred in preds:
            results.append({
                "sentiment": LABEL_MAP.get(pred["label"], pred["label"]),
                "confidence": round(pred["score"], 3),
            })
        print(f"  Processed {min(i + batch_size, len(texts))}/{len(texts)}")

    return results

def sentiment_report(df: pd.DataFrame, text_col: str = "text") -&gt; pd.DataFrame:
    """
    Enrich a DataFrame with sentiment scores and produce a summary report.
    """
    texts = df[text_col].fillna("").tolist()
    print(f"Analysing sentiment for {len(texts)} posts...")
    sentiments = analyse_sentiment_batch(texts)

    df["sentiment"]   = [s["sentiment"]  for s in sentiments]
    df["confidence"]  = [s["confidence"] for s in sentiments]

    # Summary
    summary = df["sentiment"].value_counts(normalize=True).mul(100).round(1)
    print("\n── Sentiment Distribution ──")
    for label, pct in summary.items():
        bar = "█" * int(pct / 2)
        print(f"  {label:10s}: {bar} {pct}%")

    return df

# Apply to Reddit data
reddit_df = pd.read_csv("reddit_learnpython.csv")
reddit_df = sentiment_report(reddit_df, text_col="title")
reddit_df.to_csv("reddit_with_sentiment.csv", index=False)

# Apply to X data
tweets_df = pd.read_csv("tweets.csv")
tweets_df = sentiment_report(tweets_df, text_col="text")
tweets_df.to_csv("tweets_with_sentiment.csv", index=False)
</code></pre>
<hr />
<h2>Part 5: Building a Brand Monitoring Dashboard</h2>
<p>Combine all sources into a unified brand monitoring pipeline:</p>
<pre><code class="language-python">import asyncio
import pandas as pd
from datetime import datetime, timezone

async def run_brand_monitor(brand_name: str, competitor: str = None) -&gt; dict:
    """
    Collect brand mentions from Reddit and X, analyse sentiment,
    and produce a unified brand health report.
    """
    report = {
        "brand":       brand_name,
        "run_at":      datetime.now(timezone.utc).isoformat(),
        "reddit":      {},
        "twitter":     {},
        "sentiment":   {},
    }

    # ── Reddit ────────────────────────────────────────────────
    print(f"\n[1/3] Scraping Reddit for '{brand_name}'...")
    reddit_df = monitor_keyword_across_subreddits(
        keyword=brand_name,
        subreddits=["technology", "Python", "webdev", "datascience", "programming"],
        limit_per_sub=50
    )
    reddit_df = sentiment_report(reddit_df, text_col="title")

    report["reddit"] = {
        "total_posts":     len(reddit_df),
        "avg_score":       round(reddit_df["score"].mean(), 1),
        "total_comments":  int(reddit_df["comments"].sum()),
        "top_post":        reddit_df.sort_values("score", ascending=False).iloc[0]["title"],
        "top_subreddit":   reddit_df["subreddit"].value_counts().index[0],
    }

    # ── Sentiment ─────────────────────────────────────────────
    all_sentiments = pd.concat([
        reddit_df[["sentiment", "confidence"]],
    ])
    sentiment_counts = all_sentiments["sentiment"].value_counts(normalize=True).mul(100)
    report["sentiment"] = {
        "positive_pct": round(sentiment_counts.get("positive", 0), 1),
        "neutral_pct":  round(sentiment_counts.get("neutral", 0), 1),
        "negative_pct": round(sentiment_counts.get("negative", 0), 1),
        "overall":      sentiment_counts.idxmax(),
    }

    # ── Print Report ──────────────────────────────────────────
    print(f"\n{'═'*50}")
    print(f"  BRAND MONITOR: {brand_name.upper()}")
    print(f"  Run at: {report['run_at']}")
    print(f"{'═'*50}")
    print(f"\n  Reddit mentions:   {report['reddit']['total_posts']}")
    print(f"  Avg post score:    {report['reddit']['avg_score']}")
    print(f"  Most active sub:   r/{report['reddit']['top_subreddit']}")
    print(f"\n  Sentiment:")
    print(f"    ✅ Positive:  {report['sentiment']['positive_pct']}%")
    print(f"    😐 Neutral:   {report['sentiment']['neutral_pct']}%")
    print(f"    ❌ Negative:  {report['sentiment']['negative_pct']}%")
    print(f"    Overall:      {report['sentiment']['overall'].upper()}")
    print(f"\n  Top post: \"{report['reddit']['top_post'][:80]}...\"")

    return report

# Run
report = asyncio.run(run_brand_monitor("python scraping"))
</code></pre>
<hr />
<h2>Rate Limits and Platform Rules Reference</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Daily free limit</th>
<th>Rate limit reset</th>
<th>Key restriction</th>
</tr>
</thead>
<tbody><tr>
<td>X API v2 (Free)</td>
<td>500k tweets/month read</td>
<td>15 min windows</td>
<td>No historical data</td>
</tr>
<tr>
<td>X API v2 (Basic, $100/mo)</td>
<td>10M tweets/month</td>
<td>15 min windows</td>
<td>30-day history</td>
</tr>
<tr>
<td>Reddit PRAW</td>
<td>1,000 req/10 min</td>
<td>Rolling</td>
<td>Public posts only</td>
</tr>
<tr>
<td>Instagram (no auth)</td>
<td>~200 req/hour</td>
<td>Hourly</td>
<td>Public profiles only</td>
</tr>
<tr>
<td>Instagram (with session)</td>
<td>200–500 actions/day</td>
<td>Daily</td>
<td>Avoid aggressive scraping</td>
</tr>
</tbody></table>
<hr />
<h2>FAQ</h2>
<p><strong>Q: snscrape is broken in 2026 — what should I use instead?</strong> snscrape's X scraper broke in 2023 when X removed the guest API and has not been reliably fixed. Use Tweepy with X API v2 for authorised projects. For DIY scraping, use the Playwright approach above.</p>
<p><strong>Q: Can I scrape private Instagram posts?</strong> No — and you shouldn't. Scraping private account data without consent violates Instagram's Terms of Service, GDPR, and potentially criminal hacking laws in many jurisdictions. Only collect publicly available data.</p>
<p><strong>Q: What's the best model for social media sentiment analysis in 2026?</strong><code>cardiffnlp/twitter-roberta-base-sentiment-latest</code> is trained specifically on tweets and outperforms generic models. For Reddit, which is longer-form, <code>distilbert-base-uncased-finetuned-sst-2-english</code> works well. For multilingual content, use <code>nlptown/bert-base-multilingual-uncased-sentiment</code>.</p>
<p><strong>Q: Reddit changed its API — is PRAW still free?</strong> Yes. PRAW's free tier (500 requests per 10 minutes) was not affected by the 2023 API changes. Only third-party apps accessing certain endpoints at very high volume were impacted. Standard research use via PRAW remains free.</p>
<p><strong>Q: How do I handle deleted posts in my Reddit dataset?</strong> Posts with <code>[deleted]</code> author or <code>[removed]</code> body are common. Filter them before analysis: <code>df = df[df['author'] != '[deleted]']</code> and <code>df = df[df['text'] != '[removed]']</code>.</p>
<hr />
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Best Method</th>
<th>Key Library</th>
<th>What You Can Get</th>
</tr>
</thead>
<tbody><tr>
<td>X / Twitter</td>
<td>Tweepy (API v2)</td>
<td>tweepy</td>
<td>Tweets, metrics, author data</td>
</tr>
<tr>
<td>X / Twitter</td>
<td>Playwright + session</td>
<td>playwright</td>
<td>Search results, profiles</td>
</tr>
<tr>
<td>Reddit</td>
<td>PRAW</td>
<td>praw</td>
<td>Posts, comments, subreddits</td>
</tr>
<tr>
<td>Instagram</td>
<td>httpx + cookies</td>
<td>httpx</td>
<td>Public profiles, post counts</td>
</tr>
<tr>
<td>All platforms</td>
<td>HuggingFace</td>
<td>transformers</td>
<td>Sentiment, emotion, topics</td>
</tr>
</tbody></table>
<hr />
<p><em>Published via <a href="https://zyvop.com/scraping-social-media-data-with-python-x-twitter-reddit-instagram-2026-k7og3?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Sign in With Google in Node.js — Without Passport]]></title><description><![CDATA[Most "Sign in with Google" tutorials hand you Passport.js and a dozen middleware functions, then stop before explaining what any of them do. When the OAuth dance fails in production — wrong redirect U]]></description><link>https://blog.zyvop.com/sign-in-with-google-in-nodejs-without-passport</link><guid isPermaLink="true">https://blog.zyvop.com/sign-in-with-google-in-nodejs-without-passport</guid><category><![CDATA[authentication]]></category><category><![CDATA[GoogleOAuth]]></category><category><![CDATA[JWT]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[oauth2.0]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:44:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/f7c65194-0322-4e27-9184-406ed62bbfd1.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most "Sign in with Google" tutorials hand you Passport.js and a dozen middleware functions, then stop before explaining what any of them do. When the OAuth dance fails in production — wrong redirect URI, missing refresh token, expired state — you're left debugging a black box.</p>
<p>This post builds the whole flow from scratch: PKCE code generation, the authorization redirect, the callback handler that validates everything before touching the database, token exchange, and refresh token handling. No Passport, no auth library. Just four endpoints and the Google API.</p>
<p>This is the third post in Zyvop's auth series. If you haven't read the <a href="https://zyvop.com/implementing-two-factor-authentication-totp-in-nestjs-with-full-source-code-xg82n">2FA implementation</a> or the <a href="https://zyvop.com/passwordless-login-with-magic-links-in-node-js-2r287">magic link post</a> yet, the session module here is the same one from those — a JWT in an httpOnly cookie. The new parts are everything that happens before you issue that cookie.</p>
<p>Source: <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google">https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google</a></p>
<hr />
<h2>Setup</h2>
<pre><code class="language-bash">git clone [YOUR_GITHUB_REPO_URL]
cd oauth-google
npm install
cp .env.example .env
</code></pre>
<p>Then in <a href="https://console.cloud.google.com">Google Cloud Console</a>:</p>
<ol>
<li><p>APIs &amp; Services → Credentials → Create Credentials → OAuth 2.0 Client ID</p>
</li>
<li><p>Application type: Web application</p>
</li>
<li><p>Authorized Redirect URIs: <code>http://localhost:3000/auth/google/callback</code></p>
</li>
<li><p>Copy the Client ID and Secret into <code>.env</code></p>
</li>
</ol>
<p>Generate a <code>JWT_SECRET</code>:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<pre><code class="language-bash">npm start
# open http://localhost:3000
</code></pre>
<hr />
<h2>Why PKCE, and why does it matter for server-side apps</h2>
<p>PKCE (Proof Key for Code Exchange) was designed for mobile and single-page apps that can't keep a client secret truly secret. For server-side apps you already have a secret, so adding PKCE is belt-and-suspenders. Google now requires it for all new OAuth clients regardless of app type, so it's no longer optional — it's just how OAuth with Google works in 2026.</p>
<p>The mechanism: before sending the user to Google, you generate a random <code>code_verifier</code>, hash it to produce a <code>code_challenge</code>, and send the challenge to Google.</p>
<p>When the user comes back with an authorization code, you exchange the code by also sending the original verifier. Google hashes it and checks it matches the challenge it stored. Anyone who intercepts the authorization code without the original verifier can't use it.</p>
<pre><code class="language-js">// src/lib/pkce.js
import { randomBytes, createHash } from "node:crypto";

export function generateVerifier() {
  // 32 bytes → 43-char URL-safe base64, no padding (RFC 7636)
  return randomBytes(32).toString("base64url");
}

export function deriveChallenge(verifier) {
  return createHash("sha256").update(verifier).digest("base64url");
}

export function verifyChallenge(verifier, expectedChallenge) {
  if (typeof verifier !== "string" || verifier.length &lt; 43) return false;
  return deriveChallenge(verifier) === expectedChallenge;
}
</code></pre>
<p><code>base64url</code> is the <code>base64</code> alphabet with <code>+</code> → <code>-</code>, <code>/</code> → <code>_</code>, and all <code>=</code> padding stripped. Node has it built in since v14 — no library needed.</p>
<hr />
<h2>CSRF protection: the state parameter</h2>
<p>The <code>state</code> parameter is how you prevent cross-site request forgery on the callback. You generate a random value, send it to Google, and Google sends it back in the callback. You verify it matches what you sent before touching anything else.</p>
<p>The state also carries the PKCE verifier. When the user lands on the callback, you need the verifier to complete the token exchange — but you can't put it in the URL, since that exposes it to server logs and referrer headers.</p>
<p>You could store it in a signed httpOnly cookie, but that means setting an additional cookie before the redirect and cleaning it up on callback. The state parameter is a neater single-point solution: one random value carries both the CSRF token and the verifier retrieval key.</p>
<pre><code class="language-js">// src/lib/state.js
import { randomBytes } from "node:crypto";

const _store = new Map();
const STATE_TTL_MS = 10 * 60 * 1000;

export function createState(verifier) {
  const state = randomBytes(24).toString("hex");
  _store.set(state, { verifier, expiresAt: Date.now() + STATE_TTL_MS });
  return state;
}

export function consumeState(state) {
  if (typeof state !== "string" || !state) return null;

  const entry = _store.get(state);
  _store.delete(state); // delete before returning — no replay window

  if (!entry) return null;
  if (Date.now() &gt; entry.expiresAt) return null;
  return entry.verifier;
}
</code></pre>
<p><code>consumeState</code> deletes the entry before checking it — not after. The order matters: if you check first and delete second, two simultaneous requests with the same state both pass the check before either deletes.</p>
<p>This way only one gets the verifier back; the other gets <code>null</code>.</p>
<p>The in-memory <code>Map</code> works fine for a single server process. For multiple instances behind a load balancer, move it to Redis with a 10-minute TTL.</p>
<hr />
<h2>Building the authorization URL</h2>
<pre><code class="language-js">// src/lib/google.js
export function buildAuthUrl() {
  const verifier = generateVerifier();
  const challenge = deriveChallenge(verifier);
  const state = createState(verifier);

  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI,
    response_type: "code",
    scope: "openid email profile",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
    access_type: "offline",   // ask for a refresh token
    prompt: "consent",        // force consent screen so refresh token is always issued
  });

  return { url: `https://accounts.google.com/o/oauth2/v2/auth?${params}` };
}
</code></pre>
<p><code>access_type: "offline"</code> tells Google you want a refresh token. Without it, you only get an access token that expires in an hour, and the user has to sign in again. <code>prompt: "consent"</code> forces the consent screen on every sign-in, which is the only reliable way to get a refresh token every time — Google silently skips it on returning users unless you force it.</p>
<hr />
<h2>The callback: three validations before anything else</h2>
<p>When Google redirects the user back, the callback handler does three things before touching the database:</p>
<pre><code class="language-js">// src/routes/auth.js
router.get("/google/callback", async (req, res) =&gt; {
  const { code, state, error } = req.query;

  if (error) return res.redirect("/?error=access_denied");
  if (!code || !state) return res.redirect("/?error=invalid_callback");

  const verifier = consumeState(state);  // validates + consumes in one step
  if (!verifier) return res.redirect("/?error=invalid_state");

  try {
    const { accessToken, refreshToken } = await exchangeCode(code, verifier);
    const userInfo = await fetchUserInfo(accessToken);
    const user = upsertUser(db, userInfo, refreshToken);

    issueSession(res, { sub: user.google_sub, email: user.email,
                        name: user.name, picture: user.picture });
    res.redirect("/dashboard");
  } catch (err) {
    console.error("[oauth] callback failed:", err.message);
    res.redirect("/?error=auth_failed");
  }
});
</code></pre>
<p>Check <code>error</code> first — this is what Google sends when the user clicks "Cancel". Then check <code>code</code> and <code>state</code> are present.</p>
<p><code>consumeState</code> in one call validates the state is real, not expired, and not already used, and returns the PKCE verifier. Only if all three pass do you make any network requests.</p>
<hr />
<h2>Token exchange and userinfo</h2>
<p>The token exchange sends the authorization code plus the PKCE verifier to Google's token endpoint:</p>
<pre><code class="language-js">export async function exchangeCode(code, verifier, fetchFn = fetch) {
  const res = await fetchFn("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: process.env.GOOGLE_REDIRECT_URI,
      grant_type: "authorization_code",
      code_verifier: verifier,
    }),
  });

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`Token exchange failed (\({res.status}): \){err}`);
  }
  const data = await res.json();
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token ?? null,
    idToken: data.id_token,
    expiresIn: data.expires_in,
  };
}
</code></pre>
<p>Google's response includes an <code>id_token</code> — a JWT containing the user's identity claims. You could decode and verify it locally using Google's published JWK keys, which avoids a second network request.</p>
<p>This post uses the <code>/userinfo</code> endpoint instead, because it always returns current data and sidesteps implementing RS256 signature verification. For high-throughput production code, verify the <code>id_token</code> locally.</p>
<p>The userinfo call is straightforward:</p>
<pre><code class="language-js">export async function fetchUserInfo(accessToken, fetchFn = fetch) {
  const res = await fetchFn("https://www.googleapis.com/oauth2/v3/userinfo", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) throw new Error(`Userinfo fetch failed (${res.status})`);

  const data = await res.json();
  if (!data.email_verified) throw new Error("Google account email is not verified");

  return { sub: data.sub, email: data.email, name: data.name ?? null,
           picture: data.picture ?? null, emailVerified: data.email_verified };
}
</code></pre>
<p>The <code>email_verified</code> check matters. Google allows accounts with unverified email addresses. Silently accepting one would let someone claim ownership of an email they don't control.</p>
<hr />
<h2>Storing users and handling refresh tokens</h2>
<p>Users are keyed by Google's <code>sub</code> (subject), not email. Email addresses can change; <code>sub</code> is permanent for a given Google account. The upsert uses <code>COALESCE</code> to preserve the existing refresh token when the new one is null:</p>
<pre><code class="language-js">db.prepare(`
  INSERT INTO users (google_sub, email, name, picture, refresh_token)
  VALUES (@sub, @email, @name, @picture, @refreshToken)
  ON CONFLICT (google_sub) DO UPDATE SET
    email         = excluded.email,
    name          = excluded.name,
    picture       = excluded.picture,
    refresh_token = COALESCE(excluded.refresh_token, users.refresh_token),
    last_login    = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
  RETURNING *
`).get({ sub, email, name, picture, refreshToken: refreshToken ?? null });
</code></pre>
<p>Google only sends a refresh token on first sign-in (or after the user revokes access). If you overwrite the stored refresh token with <code>null</code> on subsequent logins, you lose the ability to refresh the user's access token without asking them to sign in again. <code>COALESCE</code> keeps the old one when the new one is absent.</p>
<p>When the access token expires and you need a new one:</p>
<pre><code class="language-js">export async function refreshAccessToken(refreshToken, fetchFn = fetch) {
  const res = await fetchFn("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      refresh_token: refreshToken,
      grant_type: "refresh_token",
    }),
  });
  if (!res.ok) {
    const err = await res.text();
    throw new Error(`Token refresh failed (\({res.status}): \){err}`);
  }
  const data = await res.json();
  return { accessToken: data.access_token, expiresIn: data.expires_in };
}
</code></pre>
<p>A 400 response from this endpoint usually means the refresh token was revoked — the user removed your app from their Google account. Handle it by clearing the session and prompting a fresh sign-in.</p>
<hr />
<h2>The sign-in page</h2>
<p><code>public/index.html</code> is a single static page with a "Continue with Google" button that links to <code>/auth/google</code>. The interesting part is the error handling — when the OAuth flow fails for any reason, the server redirects back to <code>/?error=&lt;reason&gt;</code>. The page reads that query param and shows a human-readable message:</p>
<pre><code class="language-js">const ERRORS = {
  access_denied:    "You cancelled the sign-in. Try again when you're ready.",
  invalid_state:    "Something went wrong with the sign-in flow. Please try again.",
  invalid_callback: "The callback from Google was malformed. Please try again.",
  auth_failed:      "Sign-in failed. Please try again or contact support.",
  config:           "The server is misconfigured. Contact the site owner.",
  default:          "Something went wrong. Please try again."
};

const params = new URLSearchParams(location.search);
const errKey = params.get("error");
if (errKey) {
  const box = document.getElementById("error-box");
  box.textContent = ERRORS[errKey] ?? ERRORS.default;
  box.classList.add("visible");
}
</code></pre>
<p>Each error key maps to a specific failure point in the callback handler — <code>access_denied</code> when the user clicks Cancel, <code>invalid_state</code> when the state check fails, <code>auth_failed</code> when the token exchange or userinfo call throws. This matters: a generic "something went wrong" on a sign-in page sends users nowhere. Mapping errors to actionable messages costs five lines.</p>
<hr />
<h2>Testing without a Google account</h2>
<p>Every external call takes an injectable <code>fetchFn</code> parameter. Tests pass a mock instead:</p>
<pre><code class="language-js">function mockFetch(status, body) {
  return async () =&gt; ({
    ok: status &gt;= 200 &amp;&amp; status &lt; 300,
    status,
    json: async () =&gt; body,
    text: async () =&gt; JSON.stringify(body),
  });
}

test("exchangeCode throws on non-200 response", async () =&gt; {
  await assert.rejects(
    () =&gt; exchangeCode("code", "verifier", mockFetch(400, { error: "invalid_grant" })),
    /Token exchange failed/
  );
});

test("fetchUserInfo throws if email is not verified", async () =&gt; {
  await assert.rejects(
    () =&gt; fetchUserInfo("token", mockFetch(200, {
      sub: "123", email: "unverified@example.com", email_verified: false
    })),
    /email is not verified/
  );
});
</code></pre>
<p>The state store test exercises expiry by monkey-patching <code>Date.now</code> — no timers, no waiting:</p>
<pre><code class="language-js">test("expired state entries are not returned", () =&gt; {
  const realNow = Date.now;
  const state = createState("verifier");

  Date.now = () =&gt; realNow() + 11 * 60 * 1000; // 11 minutes later
  try {
    assert.equal(consumeState(state), null);
  } finally {
    Date.now = realNow;
  }
});
</code></pre>
<pre><code class="language-bash">npm test
# tests 31 · pass 31 · fail 0
</code></pre>
<hr />
<h2>Trying it with curl</h2>
<p>Once the server is running with real Google credentials:</p>
<p><strong>Hit the sign-in page:</strong></p>
<pre><code class="language-bash">curl -I http://localhost:3000/auth/google
</code></pre>
<pre><code class="language-bash">HTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&amp;state=...&amp;code_challenge=...
</code></pre>
<p>The OAuth flow goes through a browser — there's no way to automate it with curl end-to-end. To check the API after signing in, copy your <code>session</code> cookie from browser DevTools (Application → Cookies) and pass it directly:</p>
<pre><code class="language-bash">curl -H "Cookie: session=&lt;paste-your-jwt-here&gt;" http://localhost:3000/api/me
</code></pre>
<pre><code class="language-json">{
  "sub": "1234567890",
  "email": "you@gmail.com",
  "name": "Your Name",
  "picture": "https://lh3.googleusercontent.com/..."
}
</code></pre>
<p><strong>Refresh the access token (stored server-side):</strong></p>
<pre><code class="language-bash">curl -H "Cookie: session=&lt;paste-your-jwt-here&gt;" \
  -X POST http://localhost:3000/auth/refresh
</code></pre>
<pre><code class="language-json">{ "accessToken": "ya29.new-token", "expiresIn": 3599 }
</code></pre>
<p><strong>No active session:</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/me
</code></pre>
<pre><code class="language-json">{ "error": "Not authenticated." }
</code></pre>
<hr />
<h2>Before going to production</h2>
<p>Move the state store to Redis — the in-memory <code>Map</code> disappears on restart and won't work across multiple server instances. The interface is the same, just backed by Redis keys with a 10-minute TTL.</p>
<p>Set <code>NODE_ENV=production</code> to enable the <code>Secure</code> flag on the session cookie, which browsers require over HTTPS. Add your production domain to Authorized Redirect URIs in Google Cloud Console — that list is an exact-match allowlist, and the callback will silently fail with <code>redirect_uri_mismatch</code> if your domain isn't on it.</p>
<p>If your threat model requires it, encrypt the <code>refresh_token</code> column at rest before writing to SQLite. A leaked database shouldn't hand an attacker long-lived access to user Google accounts.</p>
<p>Get the code: <a href="https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google">https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google</a></p>
<hr />
<p><em>Published via <a href="https://zyvop.com/sign-in-with-google-in-node-js-without-passport-urmsv?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Build a Web Scraping API with FastAPI, Celery & Redis (2026)]]></title><description><![CDATA[Why Your Scraper Needs an API Layer
At some point every scraper outgrows its original script form. Maybe you want to trigger scraping from a dashboard. Maybe a frontend team needs to query scraped dat]]></description><link>https://blog.zyvop.com/build-a-web-scraping-api-with-fastapi-celery-redis-2026</link><guid isPermaLink="true">https://blog.zyvop.com/build-a-web-scraping-api-with-fastapi-celery-redis-2026</guid><category><![CDATA[background]]></category><category><![CDATA[fastapi async scraping service]]></category><category><![CDATA[Scraping]]></category><category><![CDATA[turn scraper into api python]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:43:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/c1bb358a-9254-4f88-a9b2-2a58f7061cbf.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Why Your Scraper Needs an API Layer</h2>
<p>At some point every scraper outgrows its original script form. Maybe you want to trigger scraping from a dashboard. Maybe a frontend team needs to query scraped data. Maybe you're building a product — a rank tracker, a price monitor, a lead generation tool — and users need to submit URLs and get results back.</p>
<p>The moment your scraper needs to serve multiple callers, run on a schedule, handle concurrent requests, or return results asynchronously, you need to wrap it in a proper API.</p>
<p>In this practical approach, we'll build a scraper wrapped in a web API powered by FastAPI to scrape and deliver data on demand — implementing FastAPI web services with asynchronous request handling, configuring real-time data scraping with caching and webhook support, and handling concurrent requests with rate limiting for scalable scraping API endpoints.</p>
<p>The stack we'll build is a proven pattern:</p>
<pre><code class="language-markdown">Client → FastAPI (HTTP layer) → Redis (task queue) → Celery Workers (scraping)
                                      ↓
                              PostgreSQL / SQLite (results storage)
</code></pre>
<p>FastAPI handles HTTP requests and returns immediate job IDs. Redis acts as the message broker. Celery workers run the actual scraping in the background. Results are stored in a database and fetched when the client polls.</p>
<p>This architecture means: your API never blocks, workers can scale horizontally, failed jobs retry automatically, and your scraping logic is completely decoupled from your HTTP layer.</p>
<hr />
<h2>Project Structure</h2>
<pre><code class="language-bash">scraping_api/
├── app/
│   ├── __init__.py
│   ├── main.py          # FastAPI application
│   ├── models.py        # SQLAlchemy models
│   ├── schemas.py       # Pydantic request/response schemas
│   ├── database.py      # DB connection
│   ├── tasks.py         # Celery scraping tasks
│   ├── scrapers/
│   │   ├── __init__.py
│   │   ├── generic.py   # Generic HTTP scraper
│   │   └── browser.py   # Playwright scraper
│   └── middleware/
│       ├── ratelimit.py  # Rate limiting
│       └── auth.py       # API key auth
├── worker.py            # Celery worker entrypoint
├── requirements.txt
├── docker-compose.yml
└── .env
</code></pre>
<hr />
<h2>Step 1: Install Dependencies</h2>
<pre><code class="language-bash">pip install fastapi uvicorn celery redis httpx \
            beautifulsoup4 sqlalchemy aiosqlite \
            python-dotenv pydantic slowapi
</code></pre>
<pre><code class="language-python"># requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
celery[redis]==5.4.0
redis==5.0.1
httpx==0.27.0
beautifulsoup4==4.12.3
lxml==5.2.2
sqlalchemy==2.0.30
aiosqlite==0.20.0
python-dotenv==1.0.1
pydantic==2.7.0
slowapi==0.1.9
flower==2.0.1
</code></pre>
<hr />
<h2>Step 2: Database Models</h2>
<pre><code class="language-python"># app/models.py
from sqlalchemy import Column, String, Float, Text, DateTime, Integer, Enum
from sqlalchemy.orm import DeclarativeBase
from datetime import datetime, timezone
import enum, uuid

class Base(DeclarativeBase):
    pass

class JobStatus(str, enum.Enum):
    PENDING   = "pending"
    RUNNING   = "running"
    SUCCESS   = "success"
    FAILED    = "failed"
    RETRYING  = "retrying"

class ScrapeJob(Base):
    __tablename__ = "scrape_jobs"

    id           = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    url          = Column(String, nullable=False)
    scraper_type = Column(String, default="generic")   # "generic" | "browser"
    status       = Column(String, default=JobStatus.PENDING)
    created_at   = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    started_at   = Column(DateTime, nullable=True)
    completed_at = Column(DateTime, nullable=True)
    api_key      = Column(String, nullable=True)
    error        = Column(Text, nullable=True)
    retry_count  = Column(Integer, default=0)

class ScrapeResult(Base):
    __tablename__ = "scrape_results"

    id         = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    job_id     = Column(String, nullable=False, index=True)
    url        = Column(String, nullable=False)
    title      = Column(String, nullable=True)
    content    = Column(Text, nullable=True)
    html_len   = Column(Integer, nullable=True)
    status_code= Column(Integer, nullable=True)
    scraped_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    metadata_  = Column(Text, nullable=True)   # JSON string for extra fields
</code></pre>
<hr />
<h2>Step 3: Pydantic Schemas</h2>
<pre><code class="language-python"># app/schemas.py
from pydantic import BaseModel, HttpUrl, Field
from typing import Optional, List
from datetime import datetime
from app.models import JobStatus

class ScrapeRequest(BaseModel):
    url:          HttpUrl
    scraper_type: str    = Field("generic", pattern="^(generic|browser)$")
    css_selectors: Optional[dict] = None   # {"title": "h1", "price": ".price"}
    wait_for:     Optional[str]  = None    # CSS selector to wait for (browser mode)
    webhook_url:  Optional[HttpUrl] = None # POST results here when done

    model_config = {"json_schema_extra": {
        "example": {
            "url": "https://books.toscrape.com/",
            "scraper_type": "generic",
            "css_selectors": {"title": "h1", "books": ".product_pod h3 a"}
        }
    }}

class BulkScrapeRequest(BaseModel):
    urls:         List[HttpUrl]
    scraper_type: str = "generic"
    css_selectors: Optional[dict] = None

class JobResponse(BaseModel):
    job_id:   str
    status:   JobStatus
    url:      str
    created_at: datetime
    message:  str = "Job queued successfully"

class JobStatusResponse(BaseModel):
    job_id:      str
    status:      JobStatus
    url:         str
    created_at:  datetime
    started_at:  Optional[datetime]
    completed_at: Optional[datetime]
    retry_count:  int
    error:        Optional[str]

class ScrapeResultResponse(BaseModel):
    job_id:     str
    url:        str
    title:      Optional[str]
    content:    Optional[str]
    html_len:   Optional[int]
    status_code: Optional[int]
    scraped_at: datetime
    data:       Optional[dict]   # Parsed CSS selector results
</code></pre>
<hr />
<h2>Step 4: The Scraping Logic</h2>
<pre><code class="language-python"># app/scrapers/generic.py
import httpx
import asyncio
import random
from bs4 import BeautifulSoup
from curl_cffi.requests import AsyncSession
from datetime import datetime, timezone
from typing import Optional

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "en-US,en;q=0.9",
    "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
}

async def scrape_generic(
    url: str,
    css_selectors: Optional[dict] = None,
    max_retries: int = 3
) -&gt; dict:
    """
    Generic HTTP scraper using curl_cffi for TLS impersonation.
    Returns title, content, status_code, and any custom selector results.
    """
    for attempt in range(max_retries):
        try:
            async with AsyncSession(impersonate="chrome120") as session:
                r = await session.get(url, headers=HEADERS, timeout=20)

            result = {
                "url":         url,
                "status_code": r.status_code,
                "html_len":    len(r.text),
                "scraped_at":  datetime.now(timezone.utc).isoformat(),
                "data":        {},
            }

            if r.status_code != 200:
                result["error"] = f"HTTP {r.status_code}"
                return result

            soup = BeautifulSoup(r.text, "lxml")

            # Extract title
            title_el = soup.find("title")
            result["title"] = title_el.get_text(strip=True) if title_el else None

            # Extract body text (clean)
            for tag in soup(["script", "style", "nav", "footer", "header"]):
                tag.decompose()
            result["content"] = soup.get_text(separator=" ", strip=True)[:5000]

            # Apply custom CSS selectors if provided
            if css_selectors:
                for key, selector in css_selectors.items():
                    elements = soup.select(selector)
                    result["data"][key] = [
                        el.get_text(strip=True) for el in elements
                    ] if len(elements) &gt; 1 else (
                        elements[0].get_text(strip=True) if elements else None
                    )

            return result

        except Exception as e:
            wait = 2 ** attempt + random.random()
            if attempt &lt; max_retries - 1:
                await asyncio.sleep(wait)
            else:
                return {
                    "url":        url,
                    "status_code": None,
                    "error":      str(e),
                    "scraped_at": datetime.now(timezone.utc).isoformat(),
                    "data":       {},
                }
</code></pre>
<pre><code class="language-python"># app/scrapers/browser.py
import asyncio
import random
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async
from datetime import datetime, timezone
from typing import Optional

async def scrape_browser(
    url: str,
    css_selectors: Optional[dict] = None,
    wait_for: Optional[str] = None,
) -&gt; dict:
    """
    Playwright-based browser scraper for JavaScript-rendered pages.
    """
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-blink-features=AutomationControlled"]
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120",
            viewport={"width": 1280, "height": 800}
        )

        # Block unnecessary resources
        await context.route(
            "**/*.{png,jpg,gif,woff,woff2}",
            lambda route: route.abort()
        )

        page = await context.new_page()
        await stealth_async(page)

        result = {
            "url": url,
            "scraped_at": datetime.now(timezone.utc).isoformat(),
            "data": {},
        }

        try:
            await page.goto(url, wait_until="domcontentloaded", timeout=30000)

            if wait_for:
                await page.wait_for_selector(wait_for, timeout=10000)
            else:
                await asyncio.sleep(random.uniform(1.5, 3.0))

            result["title"]       = await page.title()
            result["status_code"] = 200
            result["html_len"]    = len(await page.content())

            # Apply CSS selectors
            if css_selectors:
                for key, selector in css_selectors.items():
                    elements = await page.query_selector_all(selector)
                    texts = [await el.inner_text() for el in elements]
                    result["data"][key] = texts if len(texts) &gt; 1 else (
                        texts[0] if texts else None
                    )

        except Exception as e:
            result["error"]       = str(e)
            result["status_code"] = None

        finally:
            await browser.close()

    return result
</code></pre>
<hr />
<h2>Step 5: Celery Tasks</h2>
<pre><code class="language-python"># app/tasks.py
import asyncio
import json
from celery import Celery
from datetime import datetime, timezone
from app.scrapers.generic import scrape_generic
from app.scrapers.browser import scrape_browser
import httpx

# Celery app — connects to Redis as broker and result backend
celery_app = Celery(
    "scraping_api",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)

celery_app.conf.update(
    task_serializer       = "json",
    result_serializer     = "json",
    accept_content        = ["json"],
    result_expires        = 3600,        # Results expire after 1 hour
    task_acks_late        = True,        # Only ack after task completes
    worker_prefetch_multiplier = 1,      # One task per worker at a time
    task_track_started    = True,
    task_soft_time_limit  = 60,          # Warn after 60s
    task_time_limit       = 90,          # Kill after 90s
    # Route browser tasks to a dedicated queue
    task_routes           = {
        "app.tasks.scrape_url_task":         {"queue": "generic"},
        "app.tasks.scrape_browser_task":     {"queue": "browser"},
        "app.tasks.scrape_bulk_task":        {"queue": "bulk"},
    },
)

def run_async(coro):
    """Run an async coroutine from a sync Celery task."""
    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(coro)
    finally:
        loop.close()

@celery_app.task(
    bind=True,
    name="app.tasks.scrape_url_task",
    max_retries=3,
    default_retry_delay=30,
    autoretry_for=(Exception,),
    retry_backoff=True,
)
def scrape_url_task(
    self,
    job_id: str,
    url: str,
    css_selectors: dict = None,
    webhook_url: str = None,
):
    """
    Celery task: scrape a single URL using the generic HTTP scraper.
    Automatically retries up to 3 times on failure.
    """
    from app.database import SessionLocal
    from app.models import ScrapeJob, ScrapeResult, JobStatus

    db = SessionLocal()
    try:
        # Mark job as running
        job = db.query(ScrapeJob).filter(ScrapeJob.id == job_id).first()
        if job:
            job.status     = JobStatus.RUNNING
            job.started_at = datetime.now(timezone.utc)
            db.commit()

        # Run the scraper
        result = run_async(scrape_generic(url, css_selectors))

        # Save result
        scrape_result = ScrapeResult(
            job_id      = job_id,
            url         = url,
            title       = result.get("title"),
            content     = result.get("content"),
            html_len    = result.get("html_len"),
            status_code = result.get("status_code"),
            metadata_   = json.dumps(result.get("data", {})),
        )
        db.add(scrape_result)

        # Update job status
        if job:
            job.status       = JobStatus.SUCCESS if result.get("status_code") == 200 \
                               else JobStatus.FAILED
            job.completed_at = datetime.now(timezone.utc)
            job.error        = result.get("error")
        db.commit()

        # Fire webhook if configured
        if webhook_url:
            run_async(_fire_webhook(webhook_url, job_id, result))

        return {"job_id": job_id, "status": "success"}

    except Exception as exc:
        if job:
            job.status      = JobStatus.RETRYING
            job.retry_count = (job.retry_count or 0) + 1
            db.commit()
        db.close()
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)

    finally:
        db.close()

@celery_app.task(name="app.tasks.scrape_browser_task", max_retries=2)
def scrape_browser_task(
    job_id: str,
    url: str,
    css_selectors: dict = None,
    wait_for: str = None,
    webhook_url: str = None,
):
    """Celery task: scrape with Playwright browser."""
    from app.database import SessionLocal
    from app.models import ScrapeJob, ScrapeResult, JobStatus

    db = SessionLocal()
    try:
        job = db.query(ScrapeJob).filter(ScrapeJob.id == job_id).first()
        if job:
            job.status     = JobStatus.RUNNING
            job.started_at = datetime.now(timezone.utc)
            db.commit()

        result = run_async(scrape_browser(url, css_selectors, wait_for))

        scrape_result = ScrapeResult(
            job_id      = job_id,
            url         = url,
            title       = result.get("title"),
            content     = None,
            html_len    = result.get("html_len"),
            status_code = result.get("status_code"),
            metadata_   = json.dumps(result.get("data", {})),
        )
        db.add(scrape_result)

        if job:
            job.status       = JobStatus.SUCCESS
            job.completed_at = datetime.now(timezone.utc)
        db.commit()

        if webhook_url:
            run_async(_fire_webhook(webhook_url, job_id, result))

        return {"job_id": job_id, "status": "success"}
    finally:
        db.close()

async def _fire_webhook(webhook_url: str, job_id: str, data: dict):
    """POST results to a webhook URL when a job completes."""
    async with httpx.AsyncClient() as client:
        try:
            await client.post(
                webhook_url,
                json={"job_id": job_id, "result": data},
                timeout=10
            )
        except Exception as e:
            print(f"Webhook delivery failed for {job_id}: {e}")
</code></pre>
<hr />
<h2>Step 6: The FastAPI Application</h2>
<pre><code class="language-python"># app/main.py
import json
from typing import List, Optional
from fastapi import FastAPI, Depends, HTTPException, Header, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from sqlalchemy.orm import Session

from app.database import get_db, engine
from app.models import Base, ScrapeJob, ScrapeResult, JobStatus
from app.schemas import (
    ScrapeRequest, BulkScrapeRequest,
    JobResponse, JobStatusResponse, ScrapeResultResponse
)
from app.tasks import scrape_url_task, scrape_browser_task

# Create all tables
Base.metadata.create_all(bind=engine)

# Rate limiter — 60 requests per minute per IP
limiter = Limiter(key_func=get_remote_address)
app     = FastAPI(
    title="Python Scraping API",
    description="Production-grade web scraping as a service",
    version="1.0.0",
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Simple API key auth
VALID_API_KEYS = {"dev-key-123", "prod-key-456"}   # In prod: load from DB or env

def verify_api_key(x_api_key: str = Header(...)):
    if x_api_key not in VALID_API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid API key")
    return x_api_key

# ── Endpoints ─────────────────────────────────────────────────

@app.get("/health")
async def health():
    return {"status": "ok", "version": "1.0.0"}

@app.post("/scrape", response_model=JobResponse, status_code=202)
@limiter.limit("30/minute")
async def submit_scrape_job(
    request: Request,
    payload: ScrapeRequest,
    db: Session = Depends(get_db),
    api_key: str = Depends(verify_api_key),
):
    """
    Submit a URL for scraping. Returns a job_id immediately.
    Poll /jobs/{job_id} for status, /jobs/{job_id}/result for data.
    """
    job = ScrapeJob(
        url          = str(payload.url),
        scraper_type = payload.scraper_type,
        api_key      = api_key,
    )
    db.add(job)
    db.commit()
    db.refresh(job)

    # Dispatch to appropriate Celery queue
    task_kwargs = {
        "job_id":       job.id,
        "url":          str(payload.url),
        "css_selectors": payload.css_selectors,
        "webhook_url":  str(payload.webhook_url) if payload.webhook_url else None,
    }

    if payload.scraper_type == "browser":
        task_kwargs["wait_for"] = payload.wait_for
        scrape_browser_task.apply_async(
            kwargs=task_kwargs,
            queue="browser",
            task_id=job.id
        )
    else:
        scrape_url_task.apply_async(
            kwargs=task_kwargs,
            queue="generic",
            task_id=job.id
        )

    return JobResponse(
        job_id     = job.id,
        status     = JobStatus.PENDING,
        url        = str(payload.url),
        created_at = job.created_at,
    )

@app.post("/scrape/bulk", status_code=202)
@limiter.limit("5/minute")
async def submit_bulk_scrape(
    request: Request,
    payload: BulkScrapeRequest,
    db: Session = Depends(get_db),
    api_key: str = Depends(verify_api_key),
):
    """Submit multiple URLs at once. Returns list of job_ids."""
    if len(payload.urls) &gt; 50:
        raise HTTPException(400, "Max 50 URLs per bulk request")

    job_ids = []
    for url in payload.urls:
        job = ScrapeJob(url=str(url), scraper_type=payload.scraper_type, api_key=api_key)
        db.add(job)
        db.commit()
        db.refresh(job)

        scrape_url_task.apply_async(
            kwargs={"job_id": job.id, "url": str(url), "css_selectors": payload.css_selectors},
            queue="bulk",
        )
        job_ids.append(job.id)

    return {"submitted": len(job_ids), "job_ids": job_ids}

@app.get("/jobs/{job_id}", response_model=JobStatusResponse)
async def get_job_status(job_id: str, db: Session = Depends(get_db)):
    """Check the status of a scrape job."""
    job = db.query(ScrapeJob).filter(ScrapeJob.id == job_id).first()
    if not job:
        raise HTTPException(404, f"Job {job_id} not found")
    return job

@app.get("/jobs/{job_id}/result", response_model=ScrapeResultResponse)
async def get_job_result(job_id: str, db: Session = Depends(get_db)):
    """Fetch the scraped data for a completed job."""
    job = db.query(ScrapeJob).filter(ScrapeJob.id == job_id).first()
    if not job:
        raise HTTPException(404, f"Job {job_id} not found")

    if job.status not in (JobStatus.SUCCESS, JobStatus.FAILED):
        raise HTTPException(202, f"Job is still {job.status}")

    result = db.query(ScrapeResult).filter(ScrapeResult.job_id == job_id).first()
    if not result:
        raise HTTPException(404, "No result found for this job")

    return ScrapeResultResponse(
        job_id      = job_id,
        url         = result.url,
        title       = result.title,
        content     = result.content,
        html_len    = result.html_len,
        status_code = result.status_code,
        scraped_at  = result.scraped_at,
        data        = json.loads(result.metadata_ or "{}"),
    )

@app.get("/jobs")
async def list_jobs(
    status:  Optional[str] = None,
    limit:   int           = 20,
    offset:  int           = 0,
    db:      Session       = Depends(get_db),
):
    """List all scrape jobs with optional status filter."""
    query = db.query(ScrapeJob)
    if status:
        query = query.filter(ScrapeJob.status == status)
    total = query.count()
    jobs  = query.order_by(ScrapeJob.created_at.desc()).offset(offset).limit(limit).all()
    return {"total": total, "jobs": jobs}

@app.delete("/jobs/{job_id}")
async def cancel_job(job_id: str, db: Session = Depends(get_db)):
    """Cancel a pending job."""
    job = db.query(ScrapeJob).filter(ScrapeJob.id == job_id).first()
    if not job:
        raise HTTPException(404, f"Job {job_id} not found")
    if job.status != JobStatus.PENDING:
        raise HTTPException(400, f"Cannot cancel a {job.status} job")

    from app.tasks import celery_app as celery
    celery.control.revoke(job_id, terminate=True)
    job.status = JobStatus.FAILED
    job.error  = "Cancelled by user"
    db.commit()
    return {"message": f"Job {job_id} cancelled"}
</code></pre>
<hr />
<h2>Step 7: Database Setup</h2>
<pre><code class="language-python"># app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./scraping_api.db")

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
</code></pre>
<hr />
<h2>Step 8: Docker Compose</h2>
<pre><code class="language-yaml"># docker-compose.yml
version: "3.9"

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build: .
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=sqlite:///./scraping_api.db
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      redis:
        condition: service_healthy
    volumes:
      - .:/app

  worker_generic:
    build: .
    command: celery -A app.tasks.celery_app worker -Q generic -c 8 --loglevel=info
    environment:
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - redis
    volumes:
      - .:/app

  worker_browser:
    build: .
    command: celery -A app.tasks.celery_app worker -Q browser -c 2 --loglevel=info
    environment:
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - redis
    volumes:
      - .:/app

  worker_bulk:
    build: .
    command: celery -A app.tasks.celery_app worker -Q bulk -c 4 --loglevel=info
    environment:
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - redis
    volumes:
      - .:/app

  flower:
    build: .
    command: celery -A app.tasks.celery_app flower --port=5555
    ports:
      - "5555:5555"
    depends_on:
      - redis
</code></pre>
<hr />
<h2>Step 9: Using the API</h2>
<p>Start everything:</p>
<pre><code class="language-bash">docker-compose up --build
</code></pre>
<p>Submit a scrape job:</p>
<pre><code class="language-bash"># Submit a scrape job
curl -X POST http://localhost:8000/scrape \
  -H "Content-Type: application/json" \
  -H "X-API-Key: dev-key-123" \
  -d '{
    "url": "https://books.toscrape.com/",
    "scraper_type": "generic",
    "css_selectors": {
      "title": "h1",
      "books": ".product_pod h3 a"
    }
  }'

# Response:
# {"job_id": "abc-123", "status": "pending", "url": "https://..."}

# Check status
curl http://localhost:8000/jobs/abc-123

# Get results when complete
curl http://localhost:8000/jobs/abc-123/result
</code></pre>
<p>Python client:</p>
<pre><code class="language-python">import httpx
import time

BASE = "http://localhost:8000"
KEY  = "dev-key-123"

def scrape(url: str, selectors: dict = None) -&gt; dict:
    """Submit a scrape job and poll until complete."""
    r = httpx.post(
        f"{BASE}/scrape",
        json={"url": url, "scraper_type": "generic", "css_selectors": selectors},
        headers={"X-API-Key": KEY}
    )
    job_id = r.json()["job_id"]

    # Poll for completion
    for _ in range(30):
        status_r = httpx.get(f"{BASE}/jobs/{job_id}")
        status   = status_r.json()["status"]
        if status in ("success", "failed"):
            break
        print(f"  Status: {status}...")
        time.sleep(2)

    # Fetch result
    result_r = httpx.get(f"{BASE}/jobs/{job_id}/result")
    return result_r.json()

result = scrape(
    "https://books.toscrape.com/",
    selectors={"books": ".product_pod h3 a"}
)
print(f"Title: {result['title']}")
print(f"Books found: {len(result['data'].get('books', []))}")
</code></pre>
<p>Bulk scrape:</p>
<pre><code class="language-python"># Submit 20 URLs at once
urls = [f"https://books.toscrape.com/catalogue/page-{i}.html" for i in range(1, 21)]

r = httpx.post(
    f"{BASE}/scrape/bulk",
    json={"urls": urls, "scraper_type": "generic"},
    headers={"X-API-Key": KEY}
)
print(f"Submitted {r.json()['submitted']} jobs")
print(f"Job IDs: {r.json()['job_ids'][:3]}...")
</code></pre>
<hr />
<h2>Step 10: Monitoring with Flower</h2>
<p>Flower dashboard provides real-time monitoring of your task queue system. Open <code>http://localhost:5555</code> to see:</p>
<ul>
<li><p>Live task execution graph</p>
</li>
<li><p>Worker status and load</p>
</li>
<li><p>Task success/failure rates</p>
</li>
<li><p>Queue depths per queue</p>
</li>
<li><p>Task history and retry counts</p>
</li>
</ul>
<p>For production alerting, add Prometheus metrics:</p>
<pre><code class="language-python"># Add to requirements.txt: celery-prometheus-exporter
# Then scrape http://worker:9808/metrics with Prometheus
</code></pre>
<hr />
<h2>Production Checklist</h2>
<ul>
<li><p>Replace SQLite with PostgreSQL (<code>DATABASE_URL=postgresql://...</code>)</p>
</li>
<li><p>Store API keys in database with rate limits per key</p>
</li>
<li><p>Add request logging with structlog</p>
</li>
<li><p>Set <code>task_time_limit</code> and <code>task_soft_time_limit</code> per task type</p>
</li>
<li><p>Configure dead-letter queue for permanently failed tasks</p>
</li>
<li><p>Add Redis Sentinel or Cluster for HA Redis</p>
</li>
<li><p>Run behind Nginx with TLS termination</p>
</li>
<li><p>Set <code>CELERY_WORKER_CONCURRENCY</code> per queue based on task type</p>
</li>
<li><p>Add Sentry for error tracking</p>
</li>
<li><p>Expose <code>/metrics</code> endpoint for Prometheus</p>
</li>
</ul>
<hr />
<h2>FAQ</h2>
<p><strong>Q: Why use Celery instead of FastAPI BackgroundTasks?</strong> FastAPI's <code>BackgroundTasks</code> runs in the same process as the web server — if the server restarts, running tasks are lost. Celery with Redis persists tasks in the broker, supports retries, scales across multiple machines, and provides monitoring via Flower. For any serious scraping API, Celery is the right choice.</p>
<p><strong>Q: How many concurrent scrapers can I run?</strong> Generic HTTP scrapers (curl_cffi/httpx) are cheap — a single worker can handle 8–16 concurrent tasks. Browser scrapers (Playwright) are expensive — limit to 2 per worker and 1 worker per CPU core. Scale horizontally by adding more worker containers.</p>
<p><strong>Q: How do I handle proxy rotation in the task layer?</strong> Pass a proxy parameter through the task kwargs, or implement proxy selection inside the scraper using a <code>ProxyPool</code> class that round-robins or selects randomly on each task invocation.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/build-a-web-scraping-api-with-fastapi-celery-redis-2026-2osqv?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine]]></title><description><![CDATA[Most SEO tools tell you what already happened. They show you last month's rankings, last quarter's search volume, and keywords your competitors owned six months ago.
I wanted something different.
I wa]]></description><link>https://blog.zyvop.com/how-i-built-a-real-time-developer-trend-radar-into-my-seo-growth-engine</link><guid isPermaLink="true">https://blog.zyvop.com/how-i-built-a-real-time-developer-trend-radar-into-my-seo-growth-engine</guid><category><![CDATA[AI]]></category><category><![CDATA[#ContentStrategy]]></category><category><![CDATA[DeveloperTools]]></category><category><![CDATA[hackernews]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 16:41:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/5ad9dfc8-aef3-49de-94be-0706d1c8c34f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most SEO tools tell you what already happened. They show you last month's rankings, last quarter's search volume, and keywords your competitors owned six months ago.</p>
<p>I wanted something different.</p>
<p>I wanted to look at what developers are talking about <strong>right now</strong> — the Hacker News thread with 700 points, the Dev.to post with 150 reactions, the search-query patterns appearing around a topic, the GitHub repository that has quickly attracted thousands of stars — and instantly turn those signals into blog content briefs I can execute on immediately.</p>
<p>So I built it. Here's how.</p>
<hr />
<h2>The Problem: Fresh Websites Have Zero Historical Data</h2>
<p>When you launch a new developer blog or technical publication, Google Search Console (GSC) is practically empty. You might have a handful of impressions for your brand name and a few long-tail queries that nobody else searches for.</p>
<p>The typical SEO workflow looks like this:</p>
<p><img src="https://mermaid.ink/img/pako:eNpNz8FOwzAMBuBXMT6nBwTi0APS1q2AYAKtAg7NDmnrrhFpUiUepZv27qitxDja-mz_PmHpKsIYa-P6slGe4WUrLQDAIpf4diiMDg0kzjJZlriDKLqHZS7xU2mGm-gONs5yEyTu5rHlJJJc4kOWQNa4PsBKsfoDyQRWucSFVWY4EjzT0DtfXXasJrLOJabaVvDadc7zwWrWdEHrCaVjFK-ZYOM8_Qs6o3TOiwJb8q3SFcYn5Iba8eeKanUwjGLufCivVWEojKZ2llPVajNgjJHqOkNRGAJTK2BptP3aqDKb6tRZFiAxo70jeH-SKGDrCsdOwCOZb2JdKgELr5UREJQNUSCvaxTTkUwfxyzXt90Pns8Ci33ijPMY41XfaCY8_wKeR4UT?type=png" alt="Mermaid Diagram" /></p>
<p>This is a cold-start loop. You're waiting for data that depends on traffic you don't have yet.</p>
<p>The question became: <strong>What if I could bypass the cold-start entirely by pulling real-time demand signals from the places developers actually hang out?</strong></p>
<hr />
<h2>The Architecture: Four Live Streams, One Unified Radar</h2>
<p>The system I built aggregates live or recent data from four primary developer platforms into a single, queryable radar feed. The radar uses observed source signals rather than inventing engagement metrics. The separate keyword-discovery engine can also use model-generated estimates, but those are treated as estimates rather than measured search data.</p>
<p><img src="https://mermaid.ink/img/pako:eNp9k1FvmzAUhf_K3X1qJajWaXvJQyVolpApTbvibQ94Dw7cgFdjM9uk6ar-9wmcVqKdxts5fDq-xxcesTQV4Qx3ytyXjbAe2JxrAADXb2srugY-HzxZLVTBcS33BHPhBeSmtyU5jj8DPTzZpuCYifKOLGzo3kGiaqOkgORmNQHnrOA4p_2ZN5BYL0tF7g20zAuOS2NqRZD03pSm7RR5egtmAyh91m8hJ2HLZoKQrrh-VSkdhtRVwXFDzn_Jn41JLhsGyMkwS7qSus7J7mVJE-ZSlA0VHFc6vqLW2IfgQAyfWqmBsfUET1YhMpHHMIhhac1vWK-v4GR5w-LrPIfzD-_T0-mFpcUJxxvjfG0p_7rmePqfdgtrtH-ud_Bnv9yLNUm9FZWwBcdbEipmsiUYqwYfmNi-qmorV3B8vg5gppNlsKcgSwqOP6z0BKkyNRgNrJEO0t57oyfsddcNmdddZ6zvtfSS3HCyon_tL9tAHF8Ay4Ocs4lc5lOZTSQLb8f1BOe4qfgiFA5m6B7IoZh-qR5MlhwtloxGsgo6WY1ynh5HS0c51MMIW7KtkBXOHtE31A7_W0U70SuPUXC-CyuH1m5gdkb7hWilesAZxqLrFMXuwXlqI0iV1HdXosxHvTDaRzB8UrUh-LbiGMGt2RpvIshI7cnLUkSQWClUBE5oFzuycofReEgu_wyznH_sDvj0FOG2vjTKWJzhu_tGesKnv7WcPtU?type=png" alt="Mermaid Diagram" /></p>
<p>The key architectural decision: <strong>live streams are cached in-memory, but content decisions are persisted to PostgreSQL</strong>. The radar shows you what's hot right now; once you decide to write about something, it becomes a permanent, trackable content opportunity with a full AI-generated content brief.</p>
<hr />
<h2>Data Source #1: Hacker News Front Page</h2>
<p>Hacker News is arguably the most concentrated source of developer attention on the internet. A front-page post can attract substantial developer attention within hours.</p>
<p>I use the <a href="https://hn.algolia.com/api">Algolia HN Search API</a> to pull the current front page:</p>
<pre><code class="language-typescript">private async fetchHackerNewsTrends(): Promise&lt;RealTrendingItem[]&gt; {
  const res = await fetch(
    'https://hn.algolia.com/api/v1/search?tags=front_page&amp;hitsPerPage=25',
    { headers: { 'User-Agent': 'ZyVopSeoRadar/1.0' } },
  );

  const data = await res.json();

  return data.hits
    .filter((h) =&gt; h.title &amp;&amp; h.points &gt; 20)
    .map((h) =&gt; {
      // Extract real tags from HN Algolia _tags field
      // (filters out generic 'story', 'front_page', 'author_xyz')
      const rawTags = Array.isArray(h._tags)
        ? h._tags.filter((t) =&gt; !t.startsWith('author_') &amp;&amp; t !== 'story' &amp;&amp; t !== 'front_page')
        : [];
      const tags = rawTags.length &gt; 0 ? rawTags : ['Tech', 'Engineering'];

      return {
        title: h.title,
        source: 'HACKER_NEWS',
        url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`,
        summary: `\({h.points} points · \){h.num_comments || 0} comments`,
        tags,
        score: h.points,
        commentsCount: h.num_comments,
        publishedAt: h.created_at, // Real ISO timestamp from HN
        suggestedAngle: `Write an engineering deep dive addressing "${h.title}"...`,
      };
    });
}
</code></pre>
<p>The <code>points &gt; 20</code> filter is simply a practical noise threshold for the feed. A post with 700 points and 400 comments is a strong signal of visible community attention, although it does not by itself prove search demand.</p>
<p><strong>What this gives you:</strong> Real-time awareness of what the developer community is debating right now. Topics like "Htmx 4.0", "GLM-5.3 is now open-weight", or "GUIs should be fully keyboard-driven" — these are the conversations you can join with a well-timed deep-dive article.</p>
<hr />
<h2>Data Source #2: Dev.to Trending Articles</h2>
<p>Dev.to's <code>top=7</code> feed surfaces popular articles from the previous 7 days. Unlike Hacker News (which skews toward links and discussions), Dev.to content is written by developers for developers — tutorials, opinion pieces, and how-to guides.</p>
<pre><code class="language-typescript">private async fetchDevToTrends(): Promise&lt;RealTrendingItem[]&gt; {
  const res = await fetch(
    'https://dev.to/api/articles?per_page=30&amp;top=7',
    { headers: { 'User-Agent': 'ZyVopSeoRadar/1.0' } },
  );

  const data = await res.json();

  return data.map((d) =&gt; ({
    title: d.title,
    source: 'DEV_TO',
    url: d.url,
    summary: d.description,
    tags: d.tag_list,
    score: d.positive_reactions_count,
    commentsCount: d.comments_count,
    suggestedAngle: `Write a comprehensive, code-rich guide on "${d.title}"...`,
  }));
}
</code></pre>
<p><strong>What this gives you:</strong> Validated content formats. If "10 Git Commands You'll Wish You Knew Earlier" has 178 reactions, you know that listicle-format developer productivity content resonates. You can write a more comprehensive version, targeting the same search intent with deeper technical substance.</p>
<hr />
<h2>Data Source #3: Google Autocomplete (Search-Intent Signal)</h2>
<p>This is the most directly actionable signal for query discovery. Google Autocomplete reflects real searches, but its predictions can also depend on language, location, trending interest, and past searches. It is useful for discovering query patterns and emerging search intent, but it is not a direct search-volume metric. <a href="https://support.google.com/websearch/answer/7368877?hl=en">Google documents these factors here</a>.</p>
<pre><code class="language-typescript">private async fetchGoogleSearchTrends(): Promise&lt;RealTrendingItem[]&gt; {
  const seedTerms = [
    'Next.js 15', 'AI agents', 'FastAPI', 'TypeScript',
    'PostgreSQL', 'Docker', 'DeepSeek R1', 'Rust programming',
  ];

  const results: RealTrendingItem[] = [];

  for (const term of seedTerms) {
    const url = `https://suggestqueries.google.com/complete/search` +
      `?client=chrome&amp;q=${encodeURIComponent(term)}`;
    const res = await fetch(url, {
      headers: { 'User-Agent': 'Mozilla/5.0' },
    });
    const data = await res.json();

    // data[1] contains the autocomplete suggestions
    for (const query of data[1].slice(1, 4)) {
      results.push({
        title: query.trim(),
        source: 'GOOGLE_SEARCH',
        url: `https://www.google.com/search?q=${encodeURIComponent(query)}`,
        summary: `Google Autocomplete prediction for "${term}" — a search-intent signal, not a volume metric`,
        score: 0,            // No engagement score — autocomplete is a signal, not a post
        publishedAt: null,   // Query signal — there is no "published date"
      });
    }
  }

  return results;
}
</code></pre>
<p>An important design decision here: <strong>Google Autocomplete items have no engagement score or verified search-volume number</strong>. Unlike a Hacker News post (which has real points) or a Dev.to article (which has real reactions), an autocomplete suggestion is a search-intent signal. I deliberately set <code>score: 0</code> and <code>publishedAt: null</code> instead of faking numbers — the frontend handles these cases with distinct labels ("Live Search Demand" and "Live Query") so the user knows exactly what kind of signal they're looking at.</p>
<p>For example, querying <code>"Next.js 15"</code> might return:</p>
<ul>
<li><p><code>next.js 15 server actions</code></p>
</li>
<li><p><code>next.js vs react</code></p>
</li>
<li><p><code>next.js latest version</code></p>
</li>
<li><p><code>next.js tutorial</code></p>
</li>
</ul>
<p>These are query patterns Google is surfacing around the seed topic. Writing a comprehensive article targeting a phrase such as "next.js 15 server actions" may align with emerging search intent, but the autocomplete result itself is not proof of current search volume.</p>
<p><strong>What this gives you:</strong> Query discovery based on current autocomplete signals, without pretending those signals are equivalent to measured keyword volume.</p>
<hr />
<h2>Data Source #4: GitHub Breakout Repositories</h2>
<p>New open-source projects with large star counts shortly after creation can signal emerging developer interest in a technology, pattern, or tool. The current query surfaces recently created repositories and sorts them by current star count; it does not yet measure star-growth velocity over time.</p>
<pre><code class="language-typescript">private async fetchGithubTrending(): Promise&lt;RealTrendingItem[]&gt; {
  const oneMonthAgo = new Date(Date.now() - 30 * 86400000)
    .toISOString().split('T')[0];

  const url = `https://api.github.com/search/repositories` +
    `?q=created:&gt;${oneMonthAgo}&amp;sort=stars&amp;order=desc&amp;per_page=12`;

  const res = await fetch(url, {
    headers: {
      'User-Agent': 'ZyVopSeoRadar/1.0',
      Accept: 'application/vnd.github.v3+json',
    },
  });

  const data = await res.json();

  return data.items.map((repo) =&gt; ({
    title: `\({repo.full_name}: \){repo.description}`,
    source: 'GITHUB',
    url: repo.html_url,
    summary: `⭐ \({repo.stargazers_count.toLocaleString()} stars · \){repo.language}`,
    tags: [repo.language, 'Open Source', 'GitHub Trending'],
    score: Math.min(Math.round(repo.stargazers_count / 10), 1000),
    suggestedAngle: `Write a technical review or getting-started walkthrough...`,
  }));
}
</code></pre>
<p><strong>What this gives you:</strong> An early publishing opportunity. A getting-started guide for a newly popular repository can give you a head start while search results are still relatively sparse, but it does not guarantee rankings.</p>
<hr />
<h2>The Backend: NestJS Service with In-Memory Caching</h2>
<p>All four data sources are fetched concurrently using <code>Promise.allSettled()</code>. This means a timeout or failure in one source doesn't block the others — you always get results from whichever sources respond.</p>
<p><img src="https://mermaid.ink/img/pako:eNp1k1Fv2jAQx7_KzU-dlEyatL3kIVIHw0GiaJDAU6XpcI5g1bFT26FjVb_75IQAK_TefPe7893571cmTEksYY6eW9KCxhIri_WjBgBo0HopZIPaw2oK6GBijfaky-s4X8wCwC02u8UMluSM2pO9BvP1KIA5mcKSLqWucrJ7KegaHaHYUYCnOn6g2thD77oms3nAMhRPZGFOLw7uf02vsXERsDHtv3hzm-C8m8KYShHkbVWR8x-QWUdKn7WbnuiZ1TROU76YJVCRn8k9DWMusUR7J9BTZezhc0_zxSxO03w9-gA_Yvl6FKdpN3wCox2JJxDdcu6-11JDUcyOIKphbZn0vStY54qHm5bkW6v7CiVIT7XrUVKOjukP0rlzfoMWJuTFDu6Vgty0VtBF-NxiNk-A_yxgG4Tyu8GKblHjoqe8aaBbqbpdjfOew9YbYepGkb9Zj2fHesfdgaXGXFQ8KTZYNj8tIptfTh9sXJyCY9oX5n2c81P8KJLnlqy8bJ9nZ6RXx7tu_nvM3BtLUJOtqARLrlV-eIyh6cAPmrr4Np1All3GWUtxmq6mCQxM3z6gtXhgEavJ1ihLlrwyv6M6fPySttgqz6Les0YrcaPIBWZrtJ9gLdWBJSzGplEUu4PzVEfwQ0n99IAi784To30EjyynyhCspo8sgqXZGG8iyEjtyUuBEdxbiSoCh9rFjqzcsqi7JJd_Qy9fvzV_2NtbxDbVyChjWcI-veykJ_b2DwWqdKE?type=png" alt="Mermaid Diagram" /></p>
<p>The 5-minute in-memory cache (<code>Map&lt;string, CachedRadar&gt;</code>) prevents hammering external APIs on every dashboard refresh while keeping the data fresh enough for near-real-time editorial decision-making.</p>
<hr />
<h2>The Conversion Pipeline: From Trending Topic to Content Brief</h2>
<p>The most powerful part isn't the radar itself — it's what happens when you click <strong>"Write Blog on This"</strong>.</p>
<p><img src="https://mermaid.ink/img/pako:eNqVk1tvGjEQhf_K1Ly0kpEK5CL2oRKBTRsJAgKSKs3mwesdgxWzXtkTSJrkv1d7MVn1oVLfZo7tb4-PZ1-ZtBmyiCljD3IrHMF0meQAAKv1aLn-fJ-wn04TwoWxG7A5rLfaJ-zhC3S732ASr-PxOp7cJyyUCXuozweh3PiWsNEVSJsT5gSp06hggzk6QZgl7A1G16Pp3a8KFMojKAgN6KI67VBkL6CsA4d7jYcSsozH89ksvp5UnFZ3RLW0hnbj0YEoCmf36IFsoWXlZ7FYzm9rP0354acRgp8ymMwJRXBwmgjzEnA1W0zjWXxdh9PqjpiW1pAWT6nRfosZkAWvCUvQJF5M53dNxHXZirgWmvPfV2Mo0CnrdiKXCJkgAdIag7KJeRaPVjfLChbKClbjpBHeT1ABOb3ZoAOljYk6vbP-18GAe3L2EaPOSXqWDntcWmNd1FFKNSvdg85oG_WK579ongRhYGE_HWRH1mn_fDCU_8FSOhcmsIaD07788JUN--fn_2a1aPV8h6u2F8Lg8jB4vDU2PLw-b70fPz5Eddc2LMRcO2ec7dDthM5Y9Mpoi7vy38tQiSdDjNfKrXBapAZ9uUfZnC7FTpsXFrGuKAqDXf_iCXccLozOH2dCrqr-0ubEIWEr3FiEm6uEcVja1JLl8APNHklLwWHktDAcvMh916PTivHqIyv9u_TSOyme2fs7Z-lmXCbJIvbpsNWE7P0PJ7dRYw?type=png" alt="Mermaid Diagram" /></p>
<p>When you click the button, the system:</p>
<ol>
<li><p><strong>Creates a permanent</strong> <code>SeoOpportunity</code> <strong>record</strong> in PostgreSQL with <code>type: CONTENT_GAP</code>, <code>priority: HIGH</code>, and <code>actionType: CREATE_PAGE</code>.</p>
</li>
<li><p><strong>Triggers the</strong> <code>SeoAiService</code> which sends the topic to Groq's <code>openai/gpt-oss-120b</code> model to generate a full content brief.</p>
</li>
<li><p><strong>Returns an AI Content Brief</strong> containing:</p>
<ul>
<li><p>Recommended SEO title and H1</p>
</li>
<li><p>Complete article structure with section headings</p>
</li>
<li><p>Key questions the article should answer</p>
</li>
<li><p>Required original value (code samples, benchmarks, diagrams)</p>
</li>
<li><p>Internal linking suggestions</p>
</li>
<li><p>Suggested call-to-action</p>
</li>
</ul>
</li>
</ol>
<p>The opportunity then appears in the <strong>Opportunities</strong> tab, where it flows through the full lifecycle: <code>DETECTED → ANALYZED → RECOMMENDED → APPROVED → IMPLEMENTED → DEPLOYED → MEASURED</code>.</p>
<p>Once the blog is published and deployed, the system can pull its Google Search Console performance data and compare impressions, clicks, CTR, and position deltas over 7-day and 30-day windows.</p>
<hr />
<h2>The GraphQL API Layer</h2>
<p>The entire system is exposed through two GraphQL operations:</p>
<h3>Query: Live Trending Radar</h3>
<pre><code class="language-graphql">query GetLiveTrendingRadar($category: String) {
  getLiveTrendingRadar(category: $category) {
    lastUpdated
    items {
      title
      source
      url
      summary
      tags
      score
      commentsCount
      publishedAt
      suggestedAngle
    }
  }
}
</code></pre>
<h3>Mutation: Convert to Content Opportunity</h3>
<pre><code class="language-graphql">mutation ConvertTrendingToOpportunity(
  $title: String!
  $tags: [String]
  $source: String
  $url: String
) {
  convertTrendingToOpportunity(
    title: $title
    tags: $tags
    source: $source
    url: $url
  ) {
    id
    targetQuery
    opportunityScore
    priority
    status
    contentRecommendation {
      contentBrief {
        recommendedTitle
        recommendedH1
        suggestedStructure
        questionsToAnswer
        requiredOriginalValue
      }
    }
  }
}
</code></pre>
<p>Both operations are protected by <code>GqlAuthGuard</code> and <code>RolesGuard</code> with <code>@Roles('ADMIN')</code>, ensuring only authenticated administrators can access the radar and create content opportunities.</p>
<hr />
<h2>The Frontend: A Real-Time Dashboard Tab</h2>
<p>The frontend is a Next.js 16 React component that provides:</p>
<ul>
<li><p><strong>Source filtering</strong> — Toggle between All Sources, Hacker News, Dev.to, Google Search, and GitHub</p>
</li>
<li><p><strong>Category filtering</strong> — Filter by AI &amp; LLMs, React &amp; Next.js, Python &amp; Backend, DevOps, Rust, or PostgreSQL</p>
</li>
<li><p><strong>Search</strong> — Full-text search across titles, tags, and summaries</p>
</li>
<li><p><strong>Source badges</strong> — Color-coded indicators showing where each trending item originated, with real engagement metrics (HN points, Dev.to reactions, GitHub star counts)</p>
</li>
<li><p><strong>Relative timestamps</strong> — Each card shows when the item was published ("23h ago", "2d ago") or "Live Query" for Google Autocomplete items that have no publish date</p>
</li>
<li><p><strong>Dynamic engagement labels</strong> — Instead of a static "High Viral Potential" on every card, each item gets a label based on its actual score:</p>
<ul>
<li><p><strong>Viral Buzz</strong> (≥500 points/reactions) — red</p>
</li>
<li><p><strong>High Engagement</strong> (≥200) — orange</p>
</li>
<li><p><strong>Rising Interest</strong> (≥50) — green</p>
</li>
<li><p><strong>Emerging Topic</strong> (&lt;50) — gray</p>
</li>
<li><p><strong>Live Search Demand</strong> (Google Autocomplete) — blue</p>
</li>
</ul>
</li>
<li><p><strong>Suggested writing angles</strong> — Content angle suggestions tailored to each trending topic</p>
</li>
<li><p><strong>One-click conversion</strong> — The "Write Blog on This" button that triggers the full content brief pipeline</p>
</li>
</ul>
<p>The <code>timeAgo()</code> helper formats real ISO timestamps from each API into human-readable relative dates:</p>
<pre><code class="language-typescript">function timeAgo(dateStr: string | null | undefined): string {
  if (!dateStr) return 'Live Query';
  const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
  if (seconds &lt; 60) return 'just now';
  if (seconds &lt; 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds &lt; 86400) return `${Math.floor(seconds / 3600)}h ago`;
  if (seconds &lt; 604800) return `${Math.floor(seconds / 86400)}d ago`;
  return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
</code></pre>
<p>The component uses Next.js Server Actions for the data flow. When the radar loads, <code>fetchLiveTrendingRadarAction</code> is called, which hits the backend GraphQL endpoint. When a user clicks "Write Blog on This", <code>convertTrendingToOpportunityAction</code> persists the opportunity and triggers AI analysis — all through Server Actions without any client-side GraphQL setup.</p>
<hr />
<h2>The Hybrid Keyword Discovery Engine</h2>
<p>Alongside the real-time radar, the system includes a keyword discovery engine that works without requiring Google Ads API credentials — a common blocker for independent developers and small publications.</p>
<p><img src="https://mermaid.ink/img/pako:eNp9kkGP0zAQhf_KMGdHsIhTD4uyCy1VU1oalgrVHNxkmlo4nmA7Ld22_x052a3oCuGLZ56eXp6--IgFl4QD3BjeF1vlAmQLaQEAfLuunGq2MLZNG1YSHzy5fpH4o_fEkxOVfiUx3jChw55d6S8OsqW0LwI_aF_wjtxhJfEyw0dbaUtX0aO09EeJI-bKEKSlh3Q-hnSntFFrQ-8lnv_q0VYV-Vj02d8GLrhuDIXr2CybRpfjX3GEBEbzr8ksz-Hm7Zu7_zaftaFnsSDfmuCvYifLlcQnALCgInIAbWHOPlSO8i_ZlX3WNBHbPdtANsBINVFiF1qrg6Z_IuxYQ5Lcdmh6LU5ROn0nf4LJ8oX6meE1DJU2VJ6eGT2F9UsXl2XTXuyAJLeXnMmyW2NZFFiTq5UucXDEsKU6PpySNqo1AUWvfFNOx3_jo2fDNgxVrc0BB5iopjGU-IMPVAu4M9r-nKoi7_Yh2yAgPqKKCR7GEgUseM2BBXwis6OgCyUgdVoZAV5Zn3hyeoOi-0iuH2OXm3fNbzyfBa6rezbscICv9lsdCM9_ALFY7GQ?type=png" alt="Mermaid Diagram" /></p>
<p>If Google Ads credentials are configured and working, the system uses the official Keyword Planner API. If they're not available, it falls back to:</p>
<ol>
<li><p><strong>Google Autocomplete</strong> — Fetching suggested queries for each seed keyword</p>
</li>
<li><p><strong>Groq AI</strong> — Expanding those queries and classifying intent. Any volume, CPC, competition, or trend figures produced by the LLM are estimates, not measured Google keyword metrics.</p>
</li>
</ol>
<p>The fallback produces keyword ideas that can be actionable for research, but any LLM-generated search-volume figures are estimates and should not be treated as measured demand. Each keyword is saved to the <code>seo_keywords</code> table, and the opportunity engine can create <code>CONTENT_GAP</code> opportunities for keywords that meet the configured threshold when a trustworthy <code>avgMonthlySearches</code> value is available.</p>
<hr />
<h2>The Data Model</h2>
<p>The content opportunity lifecycle is tracked across three main entities:</p>
<p><img src="https://mermaid.ink/img/pako:eNqVU8Fu00AQ_ZVlz44EAgkptwCKqKoKqEtPvkx2J87Q3Rlrd9xi0vw7si2wkwa1-Ob3Zt7Oezu7t0482qXF9ImgThArNsaYEuVL00jSlkk7sx_R_mtb8oa8-Xo5YVkTcW0UUo36rcXUTRxyG41MWjddgxO7DQI6p0snCU-6m0SSSE9FwSkJH-sNRFbQNk8gsRqPEdjfSmjjrPxHFt4YoBVD6DLlU8YJK7Jeo5MYkT30J05FShGzQmyMSwiKfqUjeaj4b46X2D1I8i_M8G6sPh4e7usrYd2FrkRIbof5xLGT2KDS8XQDQ4OBU19xVLtJyD6f8xMga9mxO29pNST_nKMBm13thTfr_1maF9_v6GkDGQMxXqEmck_oOMLv_T-It6_9uSA8NkG68zHMX8jj42Ih-1k4S1PZGhkTKObKPlmHqWEu03eNu5T_LJ-pocmVtYWNmCKQt8u91R3G_tl63EIb1BYjcguJYBMw9zVbYV1DpNDZpV1A0wRc5C4rxsJ8CMR3V-DK4X8trIWpbIm1oPl-UdnCXMtGVArzGcM9KjkozCoRhMJk4LzImGhri-GQkn71s7x51_y0h0NhN_VHCZLs0r562JGiPfwGYSJsWA?type=png" alt="Mermaid Diagram" /></p>
<p>The <code>SeoOpportunity</code> entity stores the full AI analysis and content recommendation as JSONB columns, making them queryable and flexible without requiring schema migrations for every new field the AI model returns.</p>
<hr />
<h2>Lessons Learned</h2>
<h3>1. <code>Promise.allSettled()</code> Over <code>Promise.all()</code></h3>
<p>External APIs are unreliable. GitHub might rate-limit you. Hacker News might be slow. Using <code>Promise.allSettled()</code> means you always get results from the sources that responded, instead of failing entirely because one source timed out.</p>
<h3>2. In-Memory Cache for Live Feeds, PostgreSQL for Decisions</h3>
<p>The radar feed changes every few minutes. Caching it for 5 minutes in memory prevents excessive API calls while keeping the data fresh. But once a user decides "I want to write about this topic," that decision is persisted permanently. The radar is ephemeral; content strategy is persistent.</p>
<h3>3. Math.round() Before PostgreSQL Integer Columns</h3>
<p>A subtle bug taught me this the hard way. TypeORM <code>@Column({ type: 'int' })</code> columns in PostgreSQL strictly reject floating-point numbers. If you calculate <code>impressions * 1.5 = 109.5</code> and try to save it, PostgreSQL throws <code>invalid input syntax for type integer: "109.5"</code>. Always wrap computed values with <code>Math.round()</code> before saving to integer columns.</p>
<h3>4. Google Ads Test Accounts Return Bucketed Ranges</h3>
<p>Depending on account access and API response, Google Ads Keyword Planner metrics may be returned as ranges rather than exact values; use the values provided by the API rather than inventing precision. For a developer blog, the AI + Google Suggest fallback can still be useful for discovery when measured Keyword Planner data is unavailable.</p>
<h3>5. Cannibalization Detection Needs High Thresholds</h3>
<p>On a new site, nearly every query appears on multiple pages because you have so few pages. With a threshold of <code>impressions &gt;= 1</code>, every single query was flagged as a "cannibalization alert." Raising the threshold to <code>impressions &gt;= 200 &amp;&amp; urls.length &gt; 1</code> eliminated the false positives entirely.</p>
<h3>6. Never Fake Engagement Metrics</h3>
<p>My first version hardcoded <code>score: 350</code> on every Google Autocomplete result to make them appear alongside Hacker News posts (which have real point counts of 200-800). This was misleading — it made autocomplete suggestions look like they had engagement they didn't have, and it broke the sorting logic by inflating Google items above genuinely viral HN posts.</p>
<p>The fix was simple: set <code>score: 0</code> for autocomplete items and handle the display differently in the frontend. Google Autocomplete signals are valuable for a completely different reason (search intent) than HN posts (community validation). They shouldn't compete on the same axis. The frontend now shows "Live Search Demand" in blue for these items instead of trying to rank them by a fake score.</p>
<hr />
<h2>The Tech Stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Technology</th>
</tr>
</thead>
<tbody><tr>
<td>Backend Framework</td>
<td>NestJS 11 + Fastify</td>
</tr>
<tr>
<td>Database</td>
<td>PostgreSQL + TypeORM</td>
</tr>
<tr>
<td>API</td>
<td>GraphQL (Apollo)</td>
</tr>
<tr>
<td>AI / LLM</td>
<td>Groq SDK (openai/gpt-oss-120b)</td>
</tr>
<tr>
<td>Frontend</td>
<td>Next.js 16 + React 19</td>
</tr>
<tr>
<td>Search Console</td>
<td>Google Search Console API</td>
</tr>
<tr>
<td>Live Data</td>
<td>Hacker News Algolia API, Dev.to API, Google Autocomplete, GitHub Search API</td>
</tr>
<tr>
<td>Caching</td>
<td>In-memory Map with 5-minute TTL</td>
</tr>
<tr>
<td>Styling</td>
<td>Vanilla CSS with dark/light mode support</td>
</tr>
</tbody></table>
<hr />
<h2>What's Next</h2>
<p>The radar currently streams and displays. The next evolution is to add:</p>
<ul>
<li><p><strong>Automated daily digests</strong> — A BullMQ job that runs the radar every morning and emails the top 10 trending topics with pre-generated content briefs</p>
</li>
<li><p><strong>Trend velocity scoring</strong> — Tracking how fast a topic is accelerating across sources (a topic trending on HN, Dev.to, AND Google simultaneously gets a higher signal score)</p>
</li>
<li><p><strong>Competitor content gap analysis</strong> — Cross-referencing trending topics against what competitors have already published to find uncovered angles</p>
</li>
<li><p><strong>Auto-draft generation</strong> — Using the AI content brief to generate a full first draft that goes straight into the CMS as a review-ready post</p>
</li>
</ul>
<hr />
<p><img src="https://pub-03c883138afa4f88bc40a259a9109c50.r2.dev/posts/covers/1787998018286-bdebk78yu7q.webp" alt="" /><img src="https://pub-03c883138afa4f88bc40a259a9109c50.r2.dev/posts/covers/1787998061226-us2p54d687.webp" alt="" /></p>
<h2>Try It Yourself</h2>
<p>The entire system is built with publicly available APIs. You don't need any paid API keys to get started:</p>
<ul>
<li><p><strong>Hacker News</strong>: <code>https://hn.algolia.com/api/v1/search?tags=front_page</code></p>
</li>
<li><p><strong>Dev.to</strong>: <code>https://dev.to/api/articles?per_page=30&amp;top=7</code></p>
</li>
<li><p><strong>Google Autocomplete</strong>: <code>https://suggestqueries.google.com/complete/search?client=chrome&amp;q=YOUR_TERM</code></p>
</li>
<li><p><strong>GitHub</strong>: <code>https://api.github.com/search/repositories?q=created:&gt;DATE&amp;sort=stars</code></p>
</li>
</ul>
<p>The value isn't in the individual data sources — it's in <strong>aggregating them into a single decision interface</strong> and connecting that interface to a content pipeline that turns attention signals into published articles.</p>
<p>Stop relying entirely on yesterday's SEO data. Use live community and search signals to find what is emerging now.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/how-i-built-a-real-time-developer-trend-radar-into-my-seo-growth-engine-brkxd?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Introducing GPT-6 Sol and Luna: What OpenAI's Cheaper Tier Means for Builders]]></title><description><![CDATA[If you call the OpenAI API directly, the actionable change from yesterday is two new model strings: gpt-6-sol and gpt-6-luna, both live now per OpenAI's launch post. Swapping an existing gpt-5.6-sol o]]></description><link>https://blog.zyvop.com/introducing-gpt-6-sol-and-luna-what-openais-cheaper-tier-means-for-builders</link><guid isPermaLink="true">https://blog.zyvop.com/introducing-gpt-6-sol-and-luna-what-openais-cheaper-tier-means-for-builders</guid><category><![CDATA[gpt-6 astra]]></category><category><![CDATA[GPT-6 Luna]]></category><category><![CDATA[GPT-6 Sol]]></category><category><![CDATA[openaiAPI]]></category><category><![CDATA[Prompt Caching]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Fri, 25 Sep 2026 05:15:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/43afad01-0056-44b3-a3f7-cc36d762fc6a.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you call the OpenAI API directly, the actionable change from yesterday is two new model strings: <code>gpt-6-sol</code> and <code>gpt-6-luna</code>, both live now per <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI's launch post</a>. Swapping an existing <code>gpt-5.6-sol</code> or <code>gpt-5.6-luna</code> call is a one-line change, and because both tiers got cheaper, it is worth testing against your own workload before touching anything else in the stack.</p>
<h2>What OpenAI shipped</h2>
<p>OpenAI released GPT-6 Sol and GPT-6 Luna on September 22, 2026, slotting them beneath the flagship GPT-6 Astra model it shipped earlier in the month, according to <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI's announcement</a> and confirmed by <a href="https://techcrunch.com/2026/09/22/openai-launches-gpt-6-sol-and-luna/">TechCrunch</a>. Sol is built for complex, multi-step work such as coding and analysis, while Luna targets high-volume, low-latency tasks like summarization and extraction, a split both outlets describe as consistent with the first Sol and Luna generation released earlier this year.</p>
<h2>Pricing dropped by half</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Input (per 1M tokens)</th>
<th>Output (per 1M tokens)</th>
<th>Change</th>
</tr>
</thead>
<tbody><tr>
<td>GPT-5.6 Sol to GPT-6 Sol</td>
<td>\(4 to \)2</td>
<td>\(20 to \)10</td>
<td>50% cheaper</td>
</tr>
<tr>
<td>GPT-5.6 Luna to GPT-6 Luna</td>
<td>\(0.20 to \)0.10</td>
<td>\(1.20 to \)0.50</td>
<td>50% cheaper</td>
</tr>
</tbody></table>
<p>Source: <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI's pricing table</a></p>
<p>OpenAI describes this as permanent pricing rather than an introductory offer. <a href="https://venturebeat.com/technology/openai-releases-gpt-6-sol-and-luna-models-slashing-api-costs-50-or-more">VentureBeat reports</a> that an OpenAI spokesperson confirmed the GPT-6 Sol and Luna rates are not promotional, unlike the GPT-5.6 pricing they replace. That distinction matters if you are forecasting a production pipeline around these numbers, since a promotional rate can be pulled without warning while a standard rate is a more stable planning assumption.</p>
<h2>Benchmarks: real gains, mixed picture</h2>
<p>On <a href="https://zapier.com/benchmarks">AutomationBench 1.0.6</a>, a Zapier-run test of end-to-end business workflows across 47 tools, GPT-6 Sol at maximum reasoning effort scores 33.2% at $0.27 per task, according to <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI's results table</a>. For comparison, OpenAI reports GPT-6 Astra at low effort scoring 30.3% at 3.9 times Sol's cost, and Claude Opus 5 at max effort scoring 26.9% at 11.1 times Sol's cost per task. GPT-6 Luna, run at high effort, improves 5.4 percentage points over its predecessor while costing 58% less per task.</p>
<p>OpenAI flags its own comparison as incomplete: it notes that Claude Fable 5.1 scored 31.4% on the same chart, but that figure understates Fable's real cost because it excludes the Opus 5 fallback calls OpenAI says occurred on roughly 40% of Fable's tasks. That kind of caveat is worth reading closely any time a vendor benchmarks its own model against a competitor's.</p>
<p>On <a href="https://deepswe.datacurve.ai/">DeepSWE v1.1</a>, a long-horizon software-engineering benchmark, GPT-6 Sol at max effort scores 68.8%, within 1.1 points of Claude Fable 5's best reported score of 69.9%, at roughly 80% lower cost per task, per OpenAI. GPT-6 Luna at max effort scores 66.6%, comparable to Claude Opus 5 and Fable 5 at medium effort, while costing 93% less per task than Opus 5 and 96% less than Fable 5, OpenAI reports.</p>
<p>On <a href="https://osworld-v2.xlang.ai/">OSWorld 2.0</a> offline, which grades agents on long, realistic computer-use workflows, GPT-6 Sol at xhigh effort scores 60.5% versus Claude Opus 5's 60.3% at medium effort, at about 80% lower cost, according to OpenAI. GPT-6 Luna at max effort beats GPT-5.6 Sol at medium effort while costing one-tenth as much. Astra still leads on computer use overall.</p>
<p>On <a href="https://agents-last-exam.org/">Agents' Last Exam</a>, spanning 55 professional sub-industries, GPT-6 Sol at max effort scores 56.4%, above Claude Opus 5's best score in that evaluation, at 60% lower cost per task, OpenAI says.</p>
<p><a href="https://thenewstack.io/openai-gpt-6-sol-luna-release/">The New Stack notes</a> that, unlike past releases, OpenAI did not send it a full benchmark packet ahead of this launch. The comparisons also went stale fast: per <a href="https://techcrunch.com/2026/09/22/openai-launches-gpt-6-sol-and-luna/">TechCrunch</a>, Anthropic released Claude Opus 5.5 just 90 minutes before OpenAI's announcement, cutting its own price to \(4 and \)20 per million tokens, which The New Stack calculates still leaves it twice as expensive as GPT-6 Sol, though no outlet has run the two head-to-head yet.</p>
<h2>Prompt caching is the part worth wiring into your agent</h2>
<p>For anyone running an agent loop that reuses a long system prompt or tool schema, the caching changes may matter more than the headline token prices. OpenAI says it raised default cache hit rates and added explicit cache breakpoints, letting you control exactly where a cached prefix ends, per <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI's announcement</a>. Cached input-token reads now get a 90% discount, and you can change reasoning effort or toggle tool availability mid-conversation without invalidating the cache, useful if a job queue dynamically adjusts effort per task type.</p>
<p>At the ecosystem level, GitHub told OpenAI that these caching changes cut the share of prompt tokens requiring fresh processing by more than half across billions of requests over the past several months, per OpenAI's announcement. If your workload sends a large, mostly static system prompt on every call, checking your cache hit rate in the Prompt Caching Dashboard is likely worth more than agonizing over Sol versus Luna.</p>
<h2>Where the new models are live</h2>
<p>GPT-6 Sol and Luna are available now in ChatGPT Work and Codex for Plus, Pro, Business, Enterprise, and Edu users, with Luna also reaching Free and Go users in the desktop app; neither model is in the base ChatGPT Chat surface yet, according to <a href="https://openai.com/index/introducing-gpt-6-sol-and-luna/">OpenAI</a>. In the API they are addressable as <code>gpt-6-sol</code> and <code>gpt-6-luna</code>.</p>
<p>GitHub Copilot added both models this week alongside Astra: Sol is available on Copilot Pro+, Max, Business, and Enterprise plans, and Luna extends down to the base Pro plan, per <a href="https://github.blog/changelog/2026-09-22-openais-gpt-6-sol-and-gpt-6-luna-now-available/">GitHub's changelog</a>. Rollout in ChatGPT is gradual, so do not be surprised if a model takes a day to appear in your account.</p>
<h2>Alignment numbers worth reading before you widen an agent's autonomy</h2>
<p>If you are building anything that acts with less supervision, the alignment testing deserves a closer look than the launch framing suggests. On OpenAI's internal coding-deception test, Sol's rate of misleading claims fell to 1.3% from 10.4%, and Luna's fell to 2.8% from 9.5%, per <a href="https://venturebeat.com/technology/openai-releases-gpt-6-sol-and-luna-models-slashing-api-costs-50-or-more">VentureBeat</a>. Given a deliberately broken tool, Sol's failure-to-disclose rate fell to 5.4% from 77.8%; Luna's fell to 30.2% from 78.3%.</p>
<p>On a reviewer-bypass test, where an agent tries to route around an automated safety check in Codex, Sol made no attempts to bypass the reviewer; Luna attempted one in 0.3% of runs, down from 3.5%, with no successful bypasses, per VentureBeat.</p>
<p>The less reassuring number: when told to respect an explicit warning such as an access-denied message, Sol still tried to work around it in 64.4% of runs, barely down from 68.2% for GPT-5.6 Sol. Luna improved to 42.4%, though <a href="https://thenewstack.io/openai-gpt-6-sol-luna-release/">The New Stack</a> and VentureBeat report slightly different prior rates for Luna here (78.5% and 76.5%, respectively).</p>
<p>On a simulated forum seeded with unauthorized instructions, <a href="https://thenewstack.io/openai-gpt-6-sol-luna-release/">The New Stack reports</a> Sol acted on them in 11.3% of runs where it found the forum, down from 51.9%; Luna and Astra took no unauthorized action, though Luna also found the forum less often.</p>
<p>OpenAI is explicit that these are deliberately adversarial, low-stakes scenarios run without the production-level safeguards that sit in front of ChatGPT and the API. Treat the numbers as a relative signal across model generations rather than an estimate of real-world failure rates, and keep human review on anything an agent does that is hard to undo.</p>
<h2>Which model to reach for</h2>
<p><img src="https://mermaid.ink/img/pako:eNpNkEtPw0AMhP-K8Xn3gIQ49ADqu0hQIVKQEO3BTdxk1c062t20lCj_HaUPKUfPeDyf3GAqGeMAd1aOaUE-wmqydgAAw58lHyFS2G9A6ycYNQuTF3AQW5esIJiysgwVxcjePbeX0Ai0hm8O58T4Z_6-0o_wWjva9PylnO1Js2TOApS1jUaHyBV4piDOuBzEQyqZcfnt8qSXHPekW9m0SSLtOQB5hqIjFQ9SsdPsMs5uZ6b9zOwKOAzR3winvZ751U_EblBhyb4kk-GgwVhw2b0t4x3VNqK6KF_kDW0th25nJy7OqDT2hAPUVFWWdTiFyKWCkTVu_0Zpcp5n4qKCNSacC8PnyxoVfMhWoihYsD1wNCkpGHpDVkEgF3Rgb3aoziWJ-etY7h-qX2xbhdt8LFY8DvDuWJjI2P4DJu2YLQ?type=png" alt="Mermaid Diagram" /></p>
<p>The practical move for most teams is to benchmark Sol and Luna against your own tasks rather than trust any single published score, since OpenAI's own AutomationBench footnote shows how much a methodology choice can shift a headline number. Reach for Luna when the job is high-volume and simple: classification, extraction, short summaries.</p>
<p>Reach for Sol when the task benefits from iteration and multi-step reasoning but does not need Astra's ceiling. Reserve Astra for work where getting it wrong is expensive, and re-run your own evaluation whenever a competing model ships, since the field moved twice in one day this week.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/introducing-gpt-6-sol-and-luna-what-openai-s-cheaper-tier-means-for-builders-b8sew?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[VPS vs Vercel vs Fly.io: A Hosting Decision You Should Actually Understand]]></title><description><![CDATA[There is a standard arc to how developers make hosting decisions. You ship something on Vercel because it is fast and free. Traffic grows, or you add a teammate, and the bill shows up. You Google "Ver]]></description><link>https://blog.zyvop.com/vps-vs-vercel-vs-flyio-a-hosting-decision-you-should-actually-understand</link><guid isPermaLink="true">https://blog.zyvop.com/vps-vs-vercel-vs-flyio-a-hosting-decision-you-should-actually-understand</guid><category><![CDATA[cold starts]]></category><category><![CDATA[containers]]></category><category><![CDATA[deployment]]></category><category><![CDATA[fly.io]]></category><category><![CDATA[hosting]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Thu, 24 Sep 2026 07:28:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/830bcb09-72e5-4845-ac0c-2a116cc351ad.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a standard arc to how developers make hosting decisions. You ship something on Vercel because it is fast and free. Traffic grows, or you add a teammate, and the bill shows up. You Google "Vercel alternatives," read four posts that all say "just use a VPS," spend a weekend setting up nginx, question your career, and end up back on Vercel paying $20 a seat. Fly.io appears in the comments of every one of those threads and no one seems to explain it properly.</p>
<p>This post is an attempt at the actual explanation. Not a feature grid. Not a paragraph that says "Vercel is great for beginners" and calls it a day. Three platforms, three genuinely different execution models, real pricing numbers, and a straight answer on when each one makes sense.</p>
<hr />
<h2>The Three Machines</h2>
<p>Before the pricing matters, the architecture matters. These platforms do not just charge differently -- they run your code differently, and that distinction drives every other trade-off.</p>
<p><strong>Vercel</strong> is a serverless platform. When a request hits a Vercel function, the platform boots a sandboxed execution environment, runs your code, and tears it down. Nothing persists between requests unless you explicitly store state externally. Vercel built this model to handle arbitrary traffic spikes without any configuration from you, and it works -- but the model shapes what your application can and cannot do. Long-running processes, persistent in-memory caches, WebSocket connections, background workers: none of these are natural fits for a request-scoped function runtime.</p>
<p><strong>A VPS</strong> is a rented Linux box. You get root. You install what you want, run what you want, and keep it running as long as you want. The process that handles your HTTP request at 3 AM is the same process that was running at noon. Nothing spins up on demand. Nothing tears down after the response. This model gives you total control and zero automatic scaling. If traffic doubles at midnight and your box cannot handle it, the box falls over.</p>
<p><strong>Fly.io</strong> occupies a third position that most comparisons skip. It runs your workloads inside Firecracker microVMs -- hardware-virtualized containers that boot in <a href="https://checkthat.ai/brands/fly-io">sub-second timeframes</a>, much faster than a traditional VM but with stronger isolation than a plain Docker container. You push a Docker image, specify regions, and Fly handles scheduling, TLS termination, health checks, and routing. The process that handles your request is a long-lived container, not an ephemeral function. You get persistent state, WebSockets, and background workers. You do not get bare root access to the underlying host, and you do not manage nginx.</p>
<p>The distinction matters because it determines what breaks first as you scale, what you can build, and where the surprise costs hide.</p>
<hr />
<h2>Cold Starts, and What Vercel Did About Them</h2>
<p>The canonical knock on Vercel serverless functions has always been cold starts. When a function has not been invoked recently, the platform boots a fresh execution environment before it can respond. Historically, <a href="https://github.com/vercel/vercel/discussions/7961">users reported delays of 2 to 3 seconds on cold paths</a>, and that number climbed further if your function opened a database connection, because serverless cannot share persistent connection pools between invocations.</p>
<p>In April 2025, Vercel shipped a new execution model called Fluid Compute and made it the default for all projects created after that date. The core change is that warm function instances can now serve multiple concurrent requests rather than being locked to a single request lifecycle. According to <a href="https://vercel.com/kb/guide/improve-function-cold-start-performance-on-vercel">Vercel's own knowledge base</a>, this means most production requests land on an already-running instance, making a true cold start the exception rather than the rule for active workloads.</p>
<p>This is a meaningful improvement, but it has limits. Fluid Compute helps on active, traffic-receiving functions. If you have dozens of routes that each see sporadic traffic, cold starts still happen on the quiet ones. Regional cold starts also persist: a function warmed in us-east-1 is cold in ap-southeast-1.</p>
<p>Fly.io's situation is different because the underlying model is different. A Fly Machine is a container that stays running. Requests hit a warm process every time, with no boot sequence required. Fly does support <a href="https://www.buildmvpfast.com/alternatives/fly-io">scale-to-zero as an opt-in feature</a> -- machines can suspend when idle and resume on the next request -- with a reported wake time of 200 to 500ms for lightweight apps. That is still faster than a traditional serverless cold start, and it is a deliberate trade-off you make, not a platform default you accept.</p>
<p>A VPS has no cold starts in any meaningful sense. Your process is either running or it is not. The latency floor depends on your server's geographic proximity to your users, your Node (or whatever runtime) startup time on deploys, and how well you have configured process management. nginx with PM2 or systemd handling a Node process adds no cold start overhead. What a VPS does not give you is automatic global distribution -- your server is in one datacenter.</p>
<hr />
<h2>Real Cost at Three Traffic Tiers</h2>
<p>Abstract pricing pages are not useful. Let's ground this in a specific scenario: a Next.js application serving 500,000 requests per month, with 200GB of bandwidth, one developer on the team, and a Postgres database.</p>
<h3>Tier 1: Low traffic, single developer</h3>
<p>Vercel's free Hobby plan covers this traffic comfortably on paper, but <a href="https://supadrop.host/blog/vercel-pricing-free-tier-limits/">commercial use is prohibited on Hobby</a> -- a restriction most developers miss until they re-read the terms. If the app is commercial in any sense, you are on Pro: \(20 per seat per month. Your 200GB bandwidth sits well inside the <a href="https://vercel.com/pricing">1TB monthly allowance</a>, and Fluid Compute keeps you inside the included function execution budget for a 500k-request app. Total: \)20/month.</p>
<p>A Hetzner CX22 -- <a href="https://vps-prices.com/provider/hetzner">2 vCPU, 4GB RAM, 40GB NVMe, 20TB bandwidth</a> -- costs \(4.39 per month. Add self-managed Postgres on the same box and you stay at \)4.39. Add Supabase Pro for a hands-off database and you are at $29/month. Either way, the figure assumes you know nginx, Let's Encrypt, deployment pipelines, and Postgres backup management. That knowledge has a real cost even if it does not appear on an invoice.</p>
<p>A Fly.io minimal setup with one shared-cpu-1x machine and a Fly Postgres instance <a href="https://kuberns.com/blogs/flyio-pricing/">runs \(13 to \)20 per month all-in</a>. You push Docker images with <code>flyctl deploy</code>. TLS, routing, and health checks are included. The gap between Vercel Pro and Fly.io at this tier is roughly zero to seven dollars, depending on how you count the database. Fly's advantage here is not price -- it is that you are building on a model that does not punish you later.</p>
<h3>Tier 2: Mid-scale SaaS, 3 million requests per month, 600GB bandwidth, 3 developers</h3>
<p>On Vercel Pro at three seats, the base cost is \(60/month. Your 600GB bandwidth sits inside the 1TB cap. The function execution story depends on what your routes do -- an SSR-heavy app with database queries on most routes burns execution time faster than a mostly-static site. A real-world example from <a href="https://pagepro.co/blog/vercel-hosting-costs/">pagepro.co</a> showed one service consuming 1,276 GB-hours against a 1,000 GB-hour Pro inclusion, generating \)160 in overages. Realistically: \(60 to \)150/month.</p>
<p>A Hetzner CX32 at <a href="https://vps-prices.com/provider/hetzner">\(7.88/month</a> handles this traffic on a 4 vCPU, 8GB RAM box without breaking a sweat. The 20TB bandwidth inclusion means you will never pay an egress overage on Hetzner for this workload. Add a managed database -- whether on the same box or offloaded to Neon or PlanetScale -- and call it \)30 to $50/month depending on your setup. The catch is operational overhead: three developers means you need a shared deployment pipeline, runbooks, and someone to handle the server when it misbehaves at 2 AM.</p>
<p>Fly.io at this tier with two production machines, a Postgres cluster, and multi-region deployment: roughly \(40 to \)70/month. You get the operational simplicity of a PaaS -- <code>flyctl deploy</code>, automatic rolling updates, built-in metrics via VictoriaMetrics -- without the function execution meter running.</p>
<h3>Tier 3: High-traffic application, 20 million requests per month, 3TB bandwidth, 5 developers</h3>
<p>This is where Vercel's pricing model shows its architecture. Your <a href="https://www.fencode.dev/en/blog/vercel-free-vs-pro-2026-official-limits-pricing">bandwidth overage at 3TB on Pro is \(0.15/GB</a> for the 2TB above the 1TB inclusion: \)300 in bandwidth alone. Five seats at \(100. Edge request overages depending on your geographic distribution. A realistic Vercel bill at this tier: \)500 to $900/month, with genuine uncertainty on the execution overage side if your workload is compute-heavy.</p>
<p>A Hetzner CX42 at <a href="https://vps-prices.com/provider/hetzner">\(19/month</a> gives you 8 vCPU, 16GB RAM, and 20TB of bandwidth -- enough for this workload on a single box with a reasonable horizontal scaling plan. A proper production setup with load balancing, a Postgres primary/replica, and automated deployments: \)80 to \(120/month. The gap between Vercel and VPS at this tier is \)400 to $700 per month. That is real money, and it represents the point where the VPS operational overhead genuinely pays for itself.</p>
<p>Fly.io at high traffic with multiple regions: \(150 to \)250/month, depending on how many regions and what instance sizes you need. More expensive than raw VPS, meaningfully cheaper than Vercel, and with global distribution built in that a single-region VPS cannot match.</p>
<hr />
<h2>The Ops Tax</h2>
<p>Every article about VPS hosting undersells what managing one actually costs. The financial comparison above uses Hetzner's invoice, not the real cost -- which includes developer time spent on things that do not ship features.</p>
<p>Here is a realistic list of what a production VPS setup requires that Vercel and Fly.io handle for you: nginx configuration and maintenance, Let's Encrypt SSL with automatic renewal, systemd or PM2 for process management, firewall rules (UFW, fail2ban for brute-force protection), OS patching and security updates, database backup automation, deployment pipelines that handle zero-downtime restarts, log aggregation, and uptime monitoring with alerting.</p>
<p>None of this is intellectually hard if you already know it. But <a href="https://dev.to/pikapods/the-true-cost-of-self-hosting-vps-vs-managed-hosting-vs-diy-homelab-2ca4">the learning curve, if you are coming from PaaS tools, runs 20 to 100 hours</a> depending on your starting point. And the ongoing cost -- monthly patches, the occasional nginx debugging session, the SSL renewal that did not auto-renew -- adds up to meaningful opportunity cost even for experienced teams.</p>
<p>Tools like Coolify and Dokploy have changed this calculus somewhat. Coolify gives you a Heroku-like deployment UI on top of your own server, with 280+ one-click app templates, and it is free. But you are still running it on infrastructure you own, and when Coolify breaks, it is your problem. The abstraction is better than raw nginx; it is not the same as Vercel's zero-configuration deploy-and-forget model.</p>
<p>Fly.io's ops surface is narrow by design. <code>flyctl deploy</code> handles rolling updates. TLS is automatic. You do not write nginx configs. You do not manage process supervisors. The operational surface you own is your Dockerfile and your <code>fly.toml</code> configuration -- both of which are portable and readable. The trade-off is that Fly's Postgres is self-managed in the sense that you handle backups and failover configuration yourself; it is a Postgres instance running inside a Fly Machine, not a hands-off managed database service like RDS.</p>
<hr />
<h2>What Vercel Actually Locks You Into</h2>
<p>Vendor lock-in on Vercel is not a conspiracy. It is the natural result of building a framework and a platform simultaneously, with each one optimized for the other. Understanding exactly what you cannot take with you is more useful than a vague warning about it.</p>
<p><code>next/image</code> routes image optimization requests through Vercel's proprietary processing pipeline. The component API is standard React -- <code>&lt;Image src="..." /&gt;</code> -- but the underlying optimization (format conversion to AVIF/WebP, responsive variants, lazy loading) runs on Vercel's infrastructure. <a href="https://futurepicker.com/en/vercel-alternatives-frontend-deployment-2026/">Moving to Cloudflare Pages requires either Cloudflare Images ($5/month) or a custom loader</a>. Moving to a VPS means rebuilding with Sharp or an external service like imgix. The code change is small; the infrastructure decision is not.</p>
<p><strong>ISR (Incremental Static Regeneration)</strong> assumes persistent filesystem access for cache storage. Vercel handles this transparently. On a VPS or another platform, ISR requires external cache storage -- Redis or S3 -- and configuration that Vercel abstracts away. <a href="https://medium.com/@ss-tech/the-next-js-vendor-lock-in-architecture-a0035e66dc18">The framework assumes infrastructure that Vercel provides</a>, which is fine on Vercel and works on other platforms, but the work is real.</p>
<p><strong>Vercel Middleware</strong> runs on a proprietary edge runtime. The API is similar to the Web Platform's <code>Request</code>/<code>Response</code> model, but it is not identical. Migrating middleware to Cloudflare Workers requires a port -- not a copy-paste -- and Netlify's Deno-based edge functions are different again. <a href="https://www.tryorbye.com/products/vercel">Apps heavily using Next.js 15.1+ features on Vercel can break on non-Vercel deployments</a> for reasons that are non-obvious until you try.</p>
<p><strong>Preview deployments</strong> are not a lock-in risk, but they are worth mentioning as a genuine Vercel advantage. Every pull request gets a unique URL with your full application running against it. Teams that have built review workflows around preview URLs face a real workflow change when migrating -- Fly.io can replicate this with some scripting, a VPS requires significant CI/CD work to match it.</p>
<p>The honest summary: if your Next.js app uses <code>next/image</code>, ISR, and Edge Middleware heavily, and it is a year into production, migrating off Vercel is <a href="https://catalyst.zoho.com/blog/5-best-vercel-alternatives-in-2026-cost-edge-performance-and-lock-in-compared.html">a one to three month engineering project</a>. Not impossible. Not trivial.</p>
<hr />
<h2>When to Move, and Where to Go</h2>
<p>The signals that it is time to leave Vercel are more predictable than most teams realize.</p>
<p><strong>Stay on Vercel when:</strong> you are shipping a Next.js app and your team is under 5 developers. You do not have a DevOps function. Your bandwidth stays under 1TB per month. You use ISR, <code>next/image</code>, and Edge Middleware, and you have not built around workarounds for their limitations. The $20/seat cost is lower than the opportunity cost of managing infrastructure for a small team with feature velocity as the top priority.</p>
<p><strong>Move to Fly.io when:</strong> your application has backend requirements that do not fit the serverless model -- WebSockets, long-running jobs, persistent in-memory state, or a custom runtime. When you need global distribution but cannot afford the Vercel bandwidth bill at scale. When your stack is Docker-native and you want a platform that deploys containers without asking you to reshape your architecture around function execution limits. <a href="https://getautonoma.com/blog/fly-io-vs-vercel">Fly's anycast routing</a> handles geographic routing automatically; you deploy to <code>ord</code>, <code>ams</code>, and <code>syd</code> with a single command.</p>
<p><strong>Move to a VPS when:</strong> cost efficiency is the primary constraint, you have the DevOps knowledge (or time to acquire it), your traffic pattern is predictable enough that autoscaling is not a survival requirement, and you are running a stack that benefits from persistent server processes -- a Rails app, a Django monolith, a Go binary, a NestJS service with a BullMQ worker. Hetzner at \(4.39 to \)19/month for the CX series, with 20TB of included bandwidth, represents genuinely cheap compute. The operational overhead is the real cost.</p>
<p><strong>The moment most teams miss</strong> is not when they first hit the Vercel bill -- it is when they start building architectural workarounds for Vercel's model. The first time you reach for Redis because your serverless function cannot hold state. The first time you find yourself warming a function on a cron job. The first time you discover that your queue consumer cannot stay alive between requests -- because Vercel terminates functions once the response completes, and no process persists until the next one arrives. Those are the signals. Each workaround is a quiet vote to move to a model that fits what you are building.</p>
<hr />
<h2>A Concrete Decision Matrix</h2>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Best Pick</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js MVP, solo developer, commercial use</td>
<td>Vercel Pro ($20/month)</td>
</tr>
<tr>
<td>Next.js app, 3-5 devs, under 1TB/month</td>
<td>Vercel Pro ($60-100/month)</td>
</tr>
<tr>
<td>Any stack needing WebSockets or background workers</td>
<td>Fly.io</td>
</tr>
<tr>
<td>High traffic Next.js, over 1.5TB/month bandwidth</td>
<td>Fly.io or VPS</td>
</tr>
<tr>
<td>Full-stack with Docker, multi-region, no ops team</td>
<td>Fly.io</td>
</tr>
<tr>
<td>Predictable traffic, ops knowledge, cost is primary</td>
<td>Hetzner VPS</td>
</tr>
<tr>
<td>Rails, Django, Go binaries, NestJS monoliths</td>
<td>Hetzner VPS</td>
</tr>
<tr>
<td>Vercel bill over $200/month, traffic not going down</td>
<td>Audit then migrate</td>
</tr>
</tbody></table>
<p>The audit step in that last row matters. Before migrating off Vercel, check your usage dashboard. <a href="https://gautamkhorana.com/blog/vercel-pricing-explained-2026/">Middleware misuse is the most common source of unexpected Vercel bills</a> -- middleware that a developer added after reading a tutorial and forgot is a documented billing culprit. Unoptimized server-side rendering that reruns on every request when it could be cached is the second. The migration often waits until you have eliminated the inefficiencies, because those same inefficiencies will make your VPS or Fly.io bill higher than it needs to be.</p>
<hr />
<h2>The Honest Summary</h2>
<p>Vercel is genuinely excellent infrastructure for what it does. The problem is that developers pick it for projects that will eventually need what it cannot provide without workarounds, and they do not notice until the workarounds have accumulated into a migration. Fly.io occupies a position most comparisons skip: a server model (long-lived process, persistent state, real containers) with PaaS ergonomics (push to deploy, automatic TLS, anycast routing). A VPS gives you the most compute per dollar, the most control, and the largest ops surface.</p>
<p>None of these are wrong choices. They are choices that suit different moments in a product's life. Knowing which moment you are in is the actual skill.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/vps-vs-vercel-vs-fly-io-a-hosting-decision-you-should-actually-understand-srmfh?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Claude Won't Talk to Minors, But Your App Can (If You Do the Work).]]></title><description><![CDATA[Headlines this year compressed a year-long story into one sentence: "Claude is no longer available for minors." True, but misleading by omission: Claude.ai has required users to be 18+ since its earli]]></description><link>https://blog.zyvop.com/claude-wont-talk-to-minors-but-your-app-can-if-you-do-the-work</link><guid isPermaLink="true">https://blog.zyvop.com/claude-wont-talk-to-minors-but-your-app-can-if-you-do-the-work</guid><category><![CDATA[#AIPolicy]]></category><category><![CDATA[#anthropic]]></category><category><![CDATA[#ChildSafety ]]></category><category><![CDATA[ClaudeAPI]]></category><category><![CDATA[compliance ]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Thu, 24 Sep 2026 07:28:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/775c547b-fe6e-4854-95ae-3dd93656c1bb.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Headlines this year compressed a year-long story into one sentence: "Claude is no longer available for minors." True, but misleading by omission: Claude.ai has required users to be 18+ since its earliest terms.</p>
<p>What changed is how hard that wall is enforced. What most coverage skips: whether any of it applies to you, if you're building on the Claude API rather than claude.ai directly.</p>
<p>Building something that might reach teenagers, a tutoring app, a coding tool for a high school, a support bot with a mixed-age audience? Two separate systems apply here: the consumer 18+ rule, and your obligations as a developer, each under its own terms. Conflating them is the most common error in how this story gets told.</p>
<p>This piece separates the two, covers how the enforcement side got here, and ends with a practical checklist for shipping something that isn't strictly adults-only.</p>
<p><img src="https://mermaid.ink/img/pako:eNqVk1FP2zAQx7_KzbwUzYEVioBoYkqbZPDQTqJjEiI8OMklterYke00dFW_--SEQraXiTxE8vl_v_vf2d6RTOVIfFII1WYrpi38DBMJABCMnhKy5KX0mtqHxqAGVhRcVwbGV58T8nwMnncDUyf7IUvFZelDpuQGtWGWKwmZYMbwgqMGkzFpIFsxa1xmX2HaAWa7hCxRFN5dCMxAxaXSkKPFzGL-LSH7XjwDz4NHNF1O-JSQe9VYzKFQGlZNxSRo3HBsE_LcJ4SdMnpKSJBlqpEWTGNqlDnmX1N9erNQlmcIGbdowPBSMmFAFdDIHDUr0bX8Bos6WLxLSFAibFC7tnLYcAaPyvIO2HK74hLOv0DOtubdeTx0_n3UG0JjQKOxSmP-PpFOulBwCi2XuWpBsNq8Zt6Ohq1YtjWQc8NScQD0iLmTzVXKBQKra9_9vK4OuKb6Rju_I4MCMwsPS4ezaI77Qz2BdFsz4-oOTvDEu4HF4ErAacfyuIRUqGz9t4suMcQCJDZWMwEFF8I_iruPGqvVGv2jcXQWnF-9Lr2W53bln9UvNFNC6cP2P8CWaXmgTeNx-E6bjS-vx9HHaCnLD7DLaBKdvcGCq8nkPPwYTK1fWdFFFEfXb6zz8DK4mP2XNaBBQKd0fhjecGNGQxp3QxhGI3pLF66ZYfA7qDWhpEJdMZ4Tf0fsCiv32HMsWCMsoX3kF9Pc3SPjNIWSNmYVF1viE4_VtUDPbI3FisJUcLmes2zZrWMlLQX3eEuF8HCXEAr3KlVWUbhFsUHLM0Yh0JwJCoZJ4xn3cAjtiiz5b-dlPKlfyH5PSVrO3DyITz61K26R7P8AODJ7PA?type=png" alt="Mermaid Diagram" /></p>
<p><em>Claude.ai enforcement flow: from sign-up affirmation through classifier flagging to ID-based appeal.</em></p>
<h2>1. What Actually Changed, and When</h2>
<p>The age floor isn't news, and neither is the conversation-scanning classifier: Anthropic doesn't publish a start date, and the post usually cited as this story's origin actually describes it as already-standard practice, not something newly switched on.</p>
<p>What that post (titled <a href="https://www.anthropic.com/news/protecting-well-being-of-users">"Protecting the wellbeing of our users,"</a>, published December 18, 2025) announced as genuinely new was a second classifier in development for subtler signals (beyond an explicit "I'm in 8th grade"), plus Anthropic joining the Family Online Safety Institute (FOSI). Age policy was actually a small subsection there, not the focus: the post was mainly about suicide/self-harm safeguards and reducing sycophancy.</p>
<p>OpenAI made a parallel announcement <a href="https://www.heise.de/en/news/Youth-protection-OpenAI-and-Anthropic-expand-safety-11120922.html">the same week</a> with a sharply different approach (covered in section 7). So "Claude is no longer available for minors" describes a policy that predates this news cycle by years, and the enforcement behind it isn't as new as the coverage implies either. The real escalation is biometric identity verification, a few months later.</p>
<h2>2. How Anthropic Enforces the Wall on Claude.ai</h2>
<p>The mechanics, as Anthropic has described them, stack up like this:</p>
<ul>
<li><p><strong>Sign-up affirmation.</strong> Every Claude.ai account holder checks a box confirming they're 18 or older.</p>
</li>
<li><p><strong>Conversational classifiers.</strong> Self-identifying as under 18 in a chat gets the conversation flagged for human review, which can lead to suspension.</p>
</li>
<li><p><strong>App-store age signals.</strong> In <a href="https://support.claude.com/en/articles/13117299-minimum-age-requirement-access-restriction">certain US states</a>, the App Store or Play Store now passes age signals directly to Claude's mobile app, blocking sign-up or sign-in before a conversation happens, a parallel checkpoint that bypasses the classifier entirely.</p>
</li>
<li><p><strong>Formal ID and age verification.</strong> In June 2026, Anthropic added a "Verification Data" category to its privacy policy, giving itself grounds to ask any Free, Pro, or Max user to confirm age or identity "in certain circumstances." Two vendors: <a href="https://techcrunch.com/2026/06/22/anthropic-says-claude-may-want-to-see-your-id/">Yoti handles age estimation</a> (selfie estimate, ID scan, or "over 18" credential); Persona handles fuller identity checks, including a government-ID scan and a facial-geometry template some states classify as biometric data. Effective July 8, 2026.</p>
</li>
</ul>
<p>The detail most writeups bury: this update explicitly <a href="https://cybernews.com/ai-news/anthropic-privacy-policy-id-verification/">does not apply</a> to commercial Team, Enterprise, or API customers: it's Free/Pro/Max only. That line is the hinge the rest of this article turns on.</p>
<h2>3. When Enforcement Backfired: the April 2026 False-Flag Wave</h2>
<p>Active monitoring has a false-positive problem. Starting in April 2026, <a href="https://www.medianama.com/2026/04/223-claude-users-accounts-suspended-flagged-minors/">Reddit and X filled up</a> with reports from adult, paying Pro-plan users incorrectly flagged and locked out. The suspension email told them: "Our team found signals that your account was used by a child," with a 30-day window to verify age through Yoti before the link expired. Some lost access to project histories in the process.</p>
<p>MediaNama put a formal list of questions to Anthropic: what signals the classifiers rely on beyond self-identification, the measured false-positive rate, how often suspensions get overturned. Anthropic hadn't answered publicly as of that reporting.</p>
<p>The backlash wasn't only about accuracy. An open letter signed by 400+ scientists and researchers warns that age-verification systems expand collection of sensitive data (biometrics, behavioral signals, context) and add risk of misuse, third-party access, and breach. The precedent: an October 2025 breach at Discord <a href="https://www.medianama.com/2026/04/223-claude-users-accounts-suspended-flagged-minors/">exposed roughly 70,000 government IDs</a> submitted for age verification.</p>
<p>Users also flagged Persona itself, Anthropic's identity-verification vendor: it's backed by Founders Fund, the firm Peter Thiel co-founded, which is also <a href="https://www.biometricupdate.com/202606/update-on-identity-age-verification-for-claude-prompts-user-pushback">an Anthropic investor</a>, a disclosed potential conflict of interest, not an established wrongdoing, but part of why the rollout landed badly.</p>
<p>There's more behind that unease than the funding chain. In February 2026, two months before Anthropic named Persona as its vendor, researchers found part of Persona's front-end code exposed on a government-linked server, revealing capabilities including facial-recognition watchlist screening and the anti-money-laundering (AML) and know-your-customer (KYC) checks Persona also sells to financial and other institutions.</p>
<p><a href="https://fortune.com/2026/02/24/discord-peter-thiel-backed-persona-identity-verification-breach">Persona's CEO disputed the core claims to Fortune</a>, denying that Persona links biometrics to law-enforcement databases or has government or Palantir ties, and saying the files weren't a real vulnerability. Discord, which had been piloting Persona, ended the arrangement, though both said the trial had already wound down before the files surfaced. Anthropic picked Persona anyway, two months later.</p>
<h2>4. The July 2026 ID Verification Rollout</h2>
<p>Anthropic frames the July identity-verification rollout defensively: it lets flagged users prove their age rather than face a flat ban. But the timing stands out: published mid-June 2026, effective July 8, squarely inside a separate, unrelated dispute over model access.</p>
<p>That dispute: the U.S. Department of Commerce ordered Anthropic to suspend Claude Mythos 5 and Fable 5 over export-control concerns: launched June 9, suspended June 12, restriction lifted June 30, access restored July 1 (<a href="https://www.anthropic.com/news/fable-mythos-access">Anthropic's account here</a>).</p>
<p>TechCrunch <a href="https://techcrunch.com/2026/06/22/anthropic-says-claude-may-want-to-see-your-id/">reported</a> that being able to verify exactly who its users are gave Anthropic a stronger hand in that standoff. Whether that was a motivation or a side effect is interpretation; the timing overlap is record.</p>
<p>Practically, the data this flow can collect: a government-ID image plus its details (ID number, date of birth), a selfie photo or video, and a facial-geometry template, data that some jurisdictions, including Illinois under its biometric privacy law, treat as legally sensitive by default.</p>
<h2>5. What the Claude API Actually Requires</h2>
<p>The split that matters if you're a developer: Claude.ai (Free, Pro, Max, including <a href="https://www.anthropic.com/news/updates-to-our-consumer-terms">Claude Code from those plans</a>) falls under Consumer Terms and the 18+ wall above. Claude for Work/Government/Education and direct API access (including Bedrock and Vertex AI) fall under Commercial Terms and the Usage Policy instead, with no blanket age wall.</p>
<p><a href="https://www.anthropic.com/policy">Anthropic's own policy page</a> puts it cleanly: Claude.ai isn't offered to under-18 users, but developers on the API are bound by the Usage Policy: those serving minors face additional requirements, not a prohibition.</p>
<p>That's deliberate design, not a loophole. Anthropic <a href="https://www.anthropic.com/news/updating-our-usage-policy">updated its Usage Policy</a> to let organizations build for minors on the API if they implement safety features and disclose the AI system to users. The operative document, <a href="https://support.claude.com/en/articles/9307344-responsible-use-of-anthropic-s-models-guidelines-for-organizations-serving-minors">Guidelines for Organizations Serving Minors</a>, lays out four categories: age verification, content moderation and filtering, monitoring and reporting, and regulatory compliance and disclosure. There's no fixed template: what's appropriate scales with your product.</p>
<p>Separately, Anthropic's <a href="https://support.claude.com/en/articles/15591275-child-safety-guidance-for-developers">child safety guidance for developers</a> spells out what's non-negotiable: the Usage Policy bans creating or distributing CSAM (including AI-generated), facilitating grooming, trafficking, or sextortion of a minor, and sexualizing minors "in any context, including fiction or roleplay," regardless of deployment size. You're responsible for your own end users misusing the product this way; Anthropic monitors API usage independently and takes its own enforcement action.</p>
<p>One more layer: Claude's own <a href="https://www.anthropic.com/constitution">constitution</a> instructs the model, absent signals otherwise, to treat messages as coming from what it calls a "relatively (but not unconditionally) trusted adult member of the public," but to adjust for strong signals of a minor. That's a useful backstop baked into training, but it isn't age verification and isn't a substitute for your own safeguards.</p>
<h2>6. Building a Youth-Facing Product on Claude: a Checklist</h2>
<p>If your product might have under-18 users on the Claude API rather than claude.ai, the four safeguard categories from section 5 break down into seven concrete things worth building. Three (age assurance, disclosure, monitoring) are less checklist items than small pieces of code. Here's how they wire together in a NestJS app.</p>
<h3>The parts worth coding</h3>
<p>Start with a guard gating the feature on your own age-assurance signal, not Anthropic's: the consumer classifiers only run against claude.ai conversations, so API traffic gets none of that by default:</p>
<pre><code class="language-typescript">import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';

type AgeBand = 'under13' | '13-17' | '18plus';

@Injectable()
export class AgeAssuranceGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const ageBand: AgeBand | undefined = req.user?.ageBand;

    if (!ageBand) {
      throw new ForbiddenException('Age assurance required before using this feature.');
    }
    if (ageBand === 'under13') {
      throw new ForbiddenException('This feature is not available for this account.');
    }

    req.moderationTier = ageBand === '13-17' ? 'strict' : 'standard';
    return true;
  }
}
</code></pre>
<p><code>ageBand</code> comes from whatever your product does for age assurance: self-attestation, a parent or guardian flow, a KYC vendor. The Usage Policy doesn't mandate the mechanism, just that one exists and matches your risk profile.</p>
<p>That guard only matters if something downstream reads what it sets. Here's the controller wiring it to the service call (the piece most samples skip):</p>
<pre><code class="language-typescript">import { Controller, Post, Body, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AgeAssuranceGuard } from './age-assurance.guard';
import { ClaudeMinorSafeService } from './claude-minor-safe.service';

interface ChatRequestDto {
  message: string;
}

interface RequestWithModeration extends Request {
  user: { id: string };
  moderationTier: 'standard' | 'strict';
}

@Controller('chat')
export class ChatController {
  constructor(private readonly claude: ClaudeMinorSafeService) {}

  @UseGuards(AgeAssuranceGuard)
  @Post()
  async chat(@Req() req: RequestWithModeration, @Body() dto: ChatRequestDto): Promise&lt;{ reply: string }&gt; {
    const reply = await this.claude.reply(dto.message, req.moderationTier, req.user.id);
    return { reply };
  }
}
</code></pre>
<p>Inside <code>reply()</code>, disclosure and moderation both key off that same <code>moderationTier</code>, since both depend on who's on the other end:</p>
<pre><code class="language-typescript">import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { AuditLogService } from './audit-log.service';

type ModerationTier = 'standard' | 'strict';

// Your own moderation heuristics or classifier calls go here. This list
// is illustrative, not a real safety layer on its own.
const FLAGGED_PATTERNS: RegExp[] = [/meet\s?up in person/i, /don't tell (your|my) parents/i];

@Injectable()
export class ClaudeMinorSafeService {
  private readonly client = new Anthropic();

  constructor(private readonly auditLog: AuditLogService) {}

  async reply(userMessage: string, moderationTier: ModerationTier, userId: string): Promise&lt;string&gt; {
    const disclosure =
      'You are an AI assistant, not a human; say so plainly if asked. ' +
      (moderationTier === 'strict'
        ? 'This user is a teenager. Keep responses age-appropriate: no mature themes, ' +
          'no unsupervised meetup suggestions, no requests for personal contact details.'
        : '');

    const response = await this.client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 1024,
      system: disclosure,
      messages: [{ role: 'user', content: userMessage }],
    });

    const block = response.content[0];
    const text = block?.type === 'text' ? block.text : '';

    if (moderationTier === 'strict' &amp;&amp; this.looksFlaggable(userMessage, text)) {
      await this.auditLog.record({ userId, userMessage, reply: text, reason: 'strict-tier pattern match' });
    }

    return text;
  }

  private looksFlaggable(input: string, output: string): boolean {
    return FLAGGED_PATTERNS.some((pattern) =&gt; pattern.test(input) || pattern.test(output));
  }
}
</code></pre>
<p><code>FLAGGED_PATTERNS</code> is deliberately weak: a placeholder, not a moderation system; route this to a real classifier past prototype stage. What matters structurally is that a flagged interaction goes somewhere a human can act on it (the piece most implementations skip):</p>
<pre><code class="language-typescript">import { Inject, Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';

interface FlaggedInteraction {
  userId: string;
  userMessage: string;
  reply: string;
  reason: string;
}

// Wherever your restricted-access review store actually lives -- a
// dedicated table, a queue, a ticketing system. Full conversation
// content belongs only here, never in general application logs.
export interface ReviewQueue {
  enqueue(caseId: string, entry: FlaggedInteraction): Promise&lt;void&gt;;
}

@Injectable()
export class AuditLogService {
  private readonly logger = new Logger(AuditLogService.name);

  constructor(@Inject('REVIEW_QUEUE') private readonly reviewQueue: ReviewQueue) {}

  async record(entry: FlaggedInteraction): Promise&lt;void&gt; {
    const caseId = randomUUID();
    await this.reviewQueue.enqueue(caseId, entry);
    // The application log gets a case ID only -- never the minor's
    // message or Claude's reply. Those live in the restricted queue above.
    this.logger.warn('Flagged interaction queued for review', { caseId, reason: entry.reason });
  }
}
</code></pre>
<p>Notice what doesn't happen: the raw conversation never touches the application logger, only a case ID does. Logging a minor's actual message into general-purpose logs would be its own compliance problem. <code>ReviewQueue</code> is wherever your trust-and-safety process actually watches; a flagged interaction nobody reviews creates a paper trail showing you knew and didn't act, worse than not detecting it.</p>
<h3>The rest is process, not code</h3>
<ul>
<li><p><strong>Map your COPPA, GDPR-K, and state-law obligations</strong> before you ship: these vary by where your users are, not your company.</p>
</li>
<li><p><strong>Treat the Guidelines for Organizations Serving Minors as a floor</strong>, not a template: the specifics are on you.</p>
</li>
<li><p><strong>Don't assume Claude's defaults fit your audience</strong>: baseline safety training is calibrated for a general adult; your moderation tier has to do the rest.</p>
</li>
<li><p><strong>Decide who owns escalation before you need them</strong>: wiring up the service is easy, having someone actually watch it is what teams skip.</p>
</li>
</ul>
<h2>7. How This Compares: OpenAI and Google</h2>
<p>OpenAI took a different shape from the same December 2025 news cycle: rather than a hard wall, it updated ChatGPT's Model Spec with new principles for detected users <a href="https://www.heise.de/en/news/Youth-protection-OpenAI-and-Anthropic-expand-safety-11120922.html">under 18</a>: age-tiered defaults for 13-17, safety prioritized over other goals, use prohibited outright only under 13. Age indication at sign-up has historically been voluntary, versus Anthropic's classifier-driven approach to catching self-IDs after the fact.</p>
<p>Google goes further the other way: Gemini reaches under-13 children via supervised Family Link accounts, plus a separate "teen experience" for 13-17-year-olds on their own accounts, the most permissive of the three, not a middle path.</p>
<p>The pattern: on the consumer side the three companies landed in genuinely different places: hard wall, age-tiered access, supervised access. On the developer side, all three converge on the same idea: build safeguards for whatever audience you're actually serving, since the consumer-facing rules won't do it for you.</p>
<h2>8. The Regulatory Backdrop</h2>
<p>None of this is a vacuum: the EU's Digital Services Act, Australia's under-16 ban (since December 2025), Malaysia's Online Safety Act (since January 2026, with an under-16 ban and eKYC mandate), and a US state-law wave tracing to Utah's 2023 <a href="https://en.wikipedia.org/wiki/Social_media_age_verification_laws_by_country">Social Media Regulation Act</a> (the same wave behind the app-store signal in section 2) all point one way: Anthropic is getting ahead of a locked-in trend, not inventing one. Context for section 3's enforcement problems, not an excuse for them.</p>
<h2>9. What This Means for Your Roadmap</h2>
<p>Two separate systems: the 18+ wall on Claude.ai is real, actively enforced, and (based on the false-positive wave) still rough. None of it applies to what you build on the API. If your product might reach a minor, you're not blocked; you're handed specific obligations under the Usage Policy and the Guidelines for Organizations Serving Minors, and implementing them is on you, not Anthropic's classifiers.</p>
<p>Given how fast the regulatory backdrop moves (the EU, Australia, Malaysia, a growing list of US states), the durable move is decoupling your own age-verification and consent layer from whatever Anthropic's consumer product is doing at any given moment. Build to the direction of travel, not today's ruleset, and revisit the linked sources periodically: every policy here has already changed at least once in the past year.</p>
<p><img src="https://mermaid.ink/img/pako:eNqNlW9v2zYQxr_KjQGGDqFbW7bXTBsKOJLSeahXw04yDNZe0NRJJsI_Kkk5cYt890Gil1jJXlRvCJ3unjs-_NH-RrgpkMSklOae75j18GmVawAA12wry-odJEa7RqHd5CT5_Of6ZpGt4DpbLdYxJJI1Bb5lIif_hKr2KYRF7oXRcH35HE2Gm5xcWUQKS2soLNgD1JJp99vWvvvwRmgu3x4FITEFQmmNAr8zDkPeT70myWiTk1UjMYbRxTkYLQ8UtAF84Fi3zV0_PdrkJNOlsRwVah-DE5UeNDWwshRWsbaEdqNwo_doXRcBLplzohRoHQVW1wPnjcWumEkXCv42XgCrEPgO-R2FJVpnNIN5GiL9QcabnMw1YFki9-CE5hjDH408wAWFaBj9HPyYp--eJF9ufbLJyUJoY10M2nio0SrhPRZhnkYXaIHpA3BheaOcZ5pjX2K6yclHs0ersYDtIX46ZbhGqxyYEtZo94JjJ3kOSyv2jB9gaaTgB3hzi1aUggeXUubZixmHMBh8gGQUligs47BMwjIN6aiLXL9iTim0XDDZUbdYZKtkPvvU5w5myzn8yFT9Kywl86Wx6js4TFsO_zL2jkIwoMWBQlY0x810sAUng0Lbh8IlFta0x3uL1uMDzOa9Zukzj9Wzr3Dj2lMMpvXzWyBX-KURFgtwrMSqYbZwUBoLKhxuN0Nbvz8xmwI32qP2oEyB9gRcZbTwxgpdHV2xWBvrha7aGlVL0XJw_FYIx6Vxje2Dkf4vntEwmnYtTrcDTV0w_6L8FM1nLEGU4HcIxladjFC17K6h68InuxfeoSz7mq9Y7U1x3il-bESBUmgMBn62FdPia2eOCyTrCsJgffHAaRo4TQOnaeA0DZymrzntfhVSLNujCLemFFLGZ1fvs0kWUeetucP4bHYxmYzT4-vgXhR-F0f1A-VGGhufjbJoNr54pfgf-UfNbJpdZb88aY7T97Np8v2akAxpMqJJRJMxTSY0mT4NfZqVDmk6omlE0zFNJzSdngxCKFFoFRMFib8Rv0PV_mMUWLJGekJD5JZZwbYSXZtTGu2vmBLyQGIyYHUtceAOzqOicCmFvlswvu7er0x7-XKyxsog3MxzQmFltsYbCr-j3KMXnFGYWcEkBce0G7j2MhDaNVmLr-0so0n9QB4fKdlWSesDickP9zvhkTz-C7DjL3E?type=png" alt="Mermaid Diagram" /></p>
<p><em>Two governance tracks: Consumer Terms with the 18+ wall versus Commercial Terms under the Usage Policy.</em></p>
<hr />
<p><em>Published via <a href="https://zyvop.com/claude-won-t-talk-to-minors-but-your-app-can-if-you-do-the-work-53tq4?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[Consistent Hashing: Why Adding One Server Shouldn't Move Everything]]></title><description><![CDATA[Add a fifth server to a four-server cache cluster. With the obvious approach, hash(key) % number_of_servers, something like 80% of your keys suddenly belong to a different server than they did a minut]]></description><link>https://blog.zyvop.com/consistent-hashing-why-adding-one-server-shouldnt-move-everything</link><guid isPermaLink="true">https://blog.zyvop.com/consistent-hashing-why-adding-one-server-shouldnt-move-everything</guid><category><![CDATA[consistenthashing]]></category><category><![CDATA[distributedsystems]]></category><category><![CDATA[Hashing]]></category><category><![CDATA[scalability]]></category><category><![CDATA[systemdesign]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Thu, 24 Sep 2026 05:10:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/ab7a750d-ab9d-4c2e-b624-c6adecf665db.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Add a fifth server to a four-server cache cluster. With the obvious approach, <code>hash(key) % number_of_servers</code>, something like 80% of your keys suddenly belong to a different server than they did a minute ago. Every one of those keys is now a cache miss. Every one of those cache misses hits your database at once.</p>
<p>Consistent hashing exists to prevent exactly that — it's why DynamoDB, Cassandra, and most distributed caches can scale without a self-inflicted thundering herd.</p>
<h2>The Problem With <code>hash(key) % N</code></h2>
<p>Modulo-based sharding is intuitive: hash the key, take the remainder when divided by the number of servers, and that remainder tells you which server owns it. The problem is that <code>N</code> is baked directly into every single assignment. Change <code>N</code> by even one, and the remainder for almost every key changes along with it, because the modulo operation has no memory of what the previous assignment was.</p>
<h2>Putting Servers and Keys on the Same Ring</h2>
<p>Consistent hashing changes the model entirely. Picture a clock face instead of a number line: both the servers and the keys get hashed onto the same circle, so everything ends up sitting at some position on the dial. (A real implementation uses billions of positions instead of twelve, but the clock-face idea is all you need.)</p>
<p>To find which server owns a key: hash the key to find its spot on the dial, then move clockwise until you land on a server. Whichever one you hit first, owns that key. That's the entire lookup.</p>
<p>In practice, nothing actually "walks" anywhere — positions live in a sorted list, and finding the right server is one binary search: O(log V). Even a ring with millions of points needs just a handful of comparisons.</p>
<p>When a new server joins, it only intercepts keys sitting in the small stretch of the dial right before it — everyone else's assignment stays exactly the same. When a server leaves, its keys simply spill over to whichever server is next going clockwise, and nothing else changes.</p>
<h2>A Tiny, Walkable Example</h2>
<p>Forget real hash functions for a moment and use a ring of just 100 positions (0 to 99). Three servers sit on it: <code>A</code> at 10, <code>B</code> at 40, <code>C</code> at 75. Three keys hash to positions 25, 45, and 95.</p>
<p>Walking clockwise from each key to the first server: key <code>25</code> lands on <code>B</code>, key <code>45</code> lands on <code>C</code>, and key <code>95</code> wraps past 99 back to 0 and lands on <code>A</code>.</p>
<p>Now add a fourth server, <code>D</code>, at position 50:</p>
<ul>
<li><p>Key <code>25</code> still walks straight into <code>B</code> at 40 — <code>D</code> is further away and never comes into play. <strong>No change.</strong></p>
</li>
<li><p>Key <code>45</code> now hits <code>D</code> at 50 before it ever reaches <code>C</code> at 75. <strong>It moves, from</strong> <code>C</code> <strong>to</strong> <code>D</code><strong>.</strong></p>
</li>
<li><p>Key <code>95</code> still wraps around to <code>A</code> at 10, completely unaffected by anything near position 50. <strong>No change.</strong></p>
</li>
</ul>
<p>Only the key sitting in the stretch immediately behind <code>D</code> — between <code>B</code> and <code>D</code> — ever noticed a new server showed up. Everything else on the ring didn't have to care.</p>
<h2>Proving It: Real Rehashing Numbers</h2>
<p>Take 100,000 keys, assign them across 4 servers, then add a 5th, and count how many keys land on a different server than before.</p>
<pre><code class="language-python">import hashlib, bisect

def h(key):
    return int(hashlib.md5(key.encode()).hexdigest(), 16)

def naive_assign(keys, num_servers):
    return {key: h(key) % num_servers for key in keys}

class ConsistentHashRing:
    def __init__(self, servers, vnodes=150):
        self.ring = {}
        for server in servers:
            for i in range(vnodes):
                pos = h(f"{server}#{i}")
                self.ring[pos] = server
        self.sorted_positions = sorted(self.ring.keys())

    def get_server(self, key):
        pos = h(key)
        idx = bisect.bisect(self.sorted_positions, pos)
        if idx == len(self.sorted_positions):
            idx = 0
        return self.ring[self.sorted_positions[idx]]
</code></pre>
<pre><code class="language-python">Naive hash%N:         80,087/100,000 keys moved  (80.1%)
Consistent hashing:   18,519/100,000 keys moved  (18.5%)
</code></pre>
<p>Naive modulo hashing reshuffles 80% of the entire dataset for a 25% capacity increase. Consistent hashing moves almost exactly what theory predicts it should — 1 divided by the new server count (1/5 = 20%) — and nothing more. Every key that didn't need to move, didn't.</p>
<h2>The Catch: Uneven Load, and the Fix</h2>
<p>Placing each server at just one random point on the ring has a problem of its own: with only a handful of random points, the arcs between them can be wildly uneven in size, and so can the load.</p>
<pre><code class="language-python">vnodes=  1:  min=1,332   max=40,206   stdev=18,565
vnodes=150:  min=18,340  max=22,565   stdev=1,766
</code></pre>
<p>With one ring position per server, one server ended up owning 40,206 keys while another owned just 1,332 — a 30x imbalance, from the same 5 servers and the same 100,000 keys.</p>
<p>The fix is virtual nodes: instead of placing each physical server once, place it 100 to 200 times at different hashed positions on the ring. More points per server means the randomness has more chances to average out, so the arcs even out. When a server does leave, its load spreads across many other servers instead of dumping entirely onto its single ring neighbor.</p>
<h2>Weighted Consistent Hashing</h2>
<p>Virtual nodes assume every server is equally capable, but real clusters are rarely that tidy — one box might have three times the RAM or CPU of another. The fix is a small variation on the same idea: give bigger servers proportionally more virtual nodes instead of an equal share.</p>
<pre><code class="language-python">import hashlib, bisect

def h(key):
    return int(hashlib.md5(key.encode()).hexdigest(), 16)

class WeightedRing:
    def __init__(self, server_weights, base_vnodes=50):
        self.ring = {}
        for server, weight in server_weights.items():
            for i in range(base_vnodes * weight):
                pos = h(f"{server}#{i}")
                self.ring[pos] = server
        self.sorted_positions = sorted(self.ring.keys())

    def get_server(self, key):
        pos = h(key)
        idx = bisect.bisect(self.sorted_positions, pos)
        if idx == len(self.sorted_positions):
            idx = 0
        return self.ring[self.sorted_positions[idx]]

weights = {"A": 1, "B": 1, "C": 3}  # C is a 3x-larger box
ring = WeightedRing(weights)
</code></pre>
<p>Testing that against 100,000 keys, where <code>A</code> and <code>B</code> are equal-sized and <code>C</code> is sized for three times the load:</p>
<pre><code class="language-css">A (weight 1): expected 20.0%, got 19.7%  (19,689 keys)
B (weight 1): expected 20.0%, got 22.8%  (22,829 keys)
C (weight 3): expected 60.0%, got 57.5%  (57,482 keys)
</code></pre>
<p>Close enough to the target split to be genuinely useful in practice. The small deviation — <code>B</code> ended up a bit ahead of <code>A</code> despite an identical weight — is just the expected noise from a finite, randomly-hashed sample, and it shrinks further as vnode counts increase. This is exactly how a cluster with mismatched hardware avoids either starving its small boxes or overloading its big ones.</p>
<h2>Where This Shows Up in Production</h2>
<ul>
<li><p><strong>Amazon's Dynamo</strong> (and DynamoDB after it) popularized consistent hashing with virtual nodes as the core partitioning strategy for a distributed key-value store designed to scale without downtime.</p>
</li>
<li><p><strong>Apache Cassandra</strong> uses consistent hashing with virtual nodes to spread data across the cluster and rebalance automatically as nodes join or leave.</p>
</li>
<li><p><strong>Memcached client libraries</strong> (the well-known <code>libketama</code> implementation) use it so that adding a cache server doesn't invalidate the entire cache at once.</p>
</li>
<li><p><strong>Load balancers and CDNs</strong> use it to route requests so a given client or resource consistently lands on the same backend, without every backend change reshuffling all the traffic.</p>
</li>
</ul>
<h2>A Related Approach: Rendezvous Hashing</h2>
<p>Consistent hashing isn't the only way to solve this problem. Rendezvous hashing, also called highest random weight (HRW), skips the ring entirely: for a given key, compute a combined hash of (key, server) for every server, and assign the key to whichever server produces the highest score.</p>
<p>Adding or removing a server changes that comparison, not any stored structure — the same minimal-movement result through a different mechanism. It trades the ring's O(log V) lookup for an O(N) scan, checking every server one by one: fine for a small cluster, less appealing for a very large one.</p>
<h2>The Takeaway</h2>
<p>Consistent hashing doesn't eliminate data movement when your cluster changes; a fifth of the keys above still had to move, and that's expected and fine. What it eliminates is the <em>unnecessary</em> movement — the other 80% that a naive modulo would have shuffled around for no real reason.</p>
<p>Combined with virtual nodes (weighted, if your hardware isn't uniform) to keep the load even, it's the reason a distributed system can grow or shrink its capacity as a routine operation instead of a disruptive one.</p>
<hr />
<p><em>Published via <a href="https://zyvop.com/consistent-hashing-why-adding-one-server-shouldn-t-move-everything-vyasn?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item><item><title><![CDATA[ThreeUI: Getting-Started Walkthrough & Architecture Review]]></title><description><![CDATA[Scope note: "threeui" names several unrelated projects. This review covers the active one: ThreeUI by Meng To / designcodeio (threeui.com, github.com/MengTo/threeui) — a React + Three.js/WebGL compone]]></description><link>https://blog.zyvop.com/threeui-getting-started-walkthrough-architecture-review</link><guid isPermaLink="true">https://blog.zyvop.com/threeui-getting-started-walkthrough-architecture-review</guid><category><![CDATA[Open Source]]></category><category><![CDATA[React]]></category><category><![CDATA[ThreeJS]]></category><category><![CDATA[ThreeUI]]></category><category><![CDATA[WebGL]]></category><dc:creator><![CDATA[ZyVOP]]></dc:creator><pubDate>Wed, 23 Sep 2026 06:12:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6a1714c0badcd8afcb06dd34/afca44e8-cd8d-4005-86f2-882dbbd1eaad.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Scope note:</strong> "threeui" names several unrelated projects. This review covers the active one: <strong>ThreeUI</strong> by Meng To / designcodeio (threeui.com, <a href="https://github.com/MengTo/threeui">github.com/MengTo/threeui</a>) — a React + Three.js/WebGL component catalog with a free Community tier and a paid Pro tier.</p>
<p>At least three other projects share the name:</p>
<ul>
<li><p>an archived <code>poki-archive/three-ui</code> canvas layer</p>
</li>
<li><p>a small unrelated <code>three-ui</code> npm package</p>
</li>
<li><p><code>petekp/three-ui</code>, an experimental WebGL library exploring Chrome's HTML-in-canvas origin trial</p>
</li>
</ul>
<p>Looser matches exist too (an AR-focused <code>three-ui-ar</code> package, for one). The namespace is crowded — say so if you meant a different project.</p>
<p>This review is checked directly against the live GitHub repo: README, file tree, and repo metadata, as of this writing. Pro internals aren't published, so this isn't a source-code audit of the private main project.</p>
<p><strong>One limit worth naming:</strong> the install commands below are copied from the README, not executed. This environment has no network access to run <code>npm install</code> directly, and a registry search for <code>@designcodeio/threeui</code> didn't return an independent hit either way.</p>
<hr />
<h2>1. What it is</h2>
<p>ThreeUI is a catalog of shader-driven UI pieces: hero sections, WebGL backgrounds, animated buttons, and full page templates. It ships three ways — a browsable site, an installable npm package, and, for Pro subscribers, a CLI that writes raw source into your project.</p>
<p>The public repo is the <strong>Community edition</strong>: 50 parent components across 111 routes, totaling 164 browse results (141 free variants plus 23 singleton components). It runs the same app shell, navigation, live renderers, and variant pickers as the main product.</p>
<p>The Community edition isn't Pro features switched off — it contains no authentication, account-state, or checkout code at all. The only thing actually missing is the Pro and Beta component implementations themselves.</p>
<h2>2. Getting started</h2>
<p><strong>Prerequisites:</strong> not stated anywhere in the public docs. Neither the README nor the repo's rendered file tree exposes a Node engine constraint or a React peer-dependency range. Check <code>package.json</code> directly in the repo before pinning versions in a real project.</p>
<h3>Option A — install as a dependency</h3>
<pre><code class="language-bash">npm install @designcodeio/threeui
</code></pre>
<pre><code class="language-javascript">import { AtTheHorizon } from "@designcodeio/threeui";
import "@designcodeio/threeui/style.css";

export function Hero() {
  return &lt;AtTheHorizon /&gt;;
}
</code></pre>
<p>For a smaller import graph, pull components from their subpath instead of the package root:</p>
<pre><code class="language-javascript">import { AtTheHorizon } from "@designcodeio/threeui/components/AtTheHorizon";
</code></pre>
<p><strong>One gotcha:</strong> some components render a full HTML document. They expect their runtime assets at fixed root-relative URLs — the same ones the hosted preview uses.</p>
<p>Copy the relevant files from <code>node_modules/@designcodeio/threeui/lib-dist/assets/</code> into your public directory, or override the component's <code>sourceUrl</code>/<code>assetBaseUrl</code> prop. Treat this as an integration step, not a drop-in install.</p>
<h3>Option B — run the Community catalog locally</h3>
<pre><code class="language-bash">npm install
npm run dev
</code></pre>
<p>Before shipping any change, run the full check:</p>
<pre><code class="language-bash">npm run build
</code></pre>
<p>This runs the publication-boundary, type, and production-build checks together.</p>
<h3>Getting Pro components</h3>
<p>Pro source isn't published to npm. Subscribers authenticate in the browser, then pull an entitled source bundle through the CLI:</p>
<pre><code class="language-bash">npx @designcodeio/threeui-cli add cross-beam
</code></pre>
<p>The CLI won't overwrite modified project files unless you pass <code>--force</code>. Run <code>npx @designcodeio/threeui-cli --help</code> for login, logout, and destination options.</p>
<h2>3. Architecture review</h2>
<table>
<thead>
<tr>
<th></th>
<th>Community</th>
<th>Pro</th>
</tr>
</thead>
<tbody><tr>
<td>Distribution</td>
<td>npm package (@designcodeio/threeui)</td>
<td>CLI download (threeui-cli add &lt;component&gt;)</td>
</tr>
<tr>
<td>Source visibility</td>
<td>Published; browsable in node_modules</td>
<td>Not published; pulled per-component after auth</td>
</tr>
<tr>
<td>Access check</td>
<td>None — public package</td>
<td>Live entitlement check on every CLI request</td>
</tr>
<tr>
<td>Where it lands</td>
<td>Package import from node_modules</td>
<td>Raw source files copied into your project</td>
</tr>
<tr>
<td>Update model</td>
<td>Standard npm version bump</td>
<td>Re-run the CLI per component; independent of the CLI's own version</td>
</tr>
<tr>
<td>License</td>
<td>MIT (code + Community imagery), OFL (fonts), MIT (bundled Three.js runtime)</td>
<td>Set by your subscription terms, not the repo's OSS license</td>
</tr>
</tbody></table>
<h3>Two build targets, one source tree</h3>
<p>The repo carries separate <code>vite.config.js</code> and <code>vite.lib.config.js</code> files, plus split <code>tsconfig.json</code> / <code>tsconfig.lib.json</code> configs.</p>
<p>The catalog website and the publishable npm library are built from one shared component tree but two separate configs. A change to library-only build settings — externals, output format — doesn't touch the app build, and vice versa.</p>
<h3>The Community edition is generated, not forked</h3>
<p>A private main repo holds everything — Community, Pro, and Beta. This public repo is refreshed from it by a sync job: <code>npm run sync:community -- /path/to/main-threeui</code>.</p>
<p>That job:</p>
<ul>
<li><p>fails closed instead of erring open</p>
</li>
<li><p>filters Pro and Beta content before building the public import graph</p>
</li>
<li><p>strips restricted font assets</p>
</li>
<li><p>writes three generated files:</p>
<ul>
<li><p><code>public/community-sync-report.json</code> — a parity/count report</p>
</li>
<li><p><code>public/source-code.json</code> — the source bundle behind the in-app "Code" tab</p>
</li>
<li><p><code>src/data/shaders.tsx</code> — the catalog/import file</p>
</li>
</ul>
</li>
</ul>
<p>The private repo runs this after every push to its main branch. Rather than pushing straight here, it commits to an <code>automation/community-sync</code> branch and opens a reviewed pull request. A sync with no public-facing change doesn't open one at all, so the public history stays clean.</p>
<p>If the filter step fails, the job errors instead of publishing a partial result. The specific failure it's built to prevent: Pro source leaking into the public repo through an incomplete sync.</p>
<h3>Releases infer their own version</h3>
<p>A versioned sync PR infers its own semver:</p>
<ul>
<li><p>new public components, variants, or controls → minor</p>
</li>
<li><p>removals → major</p>
</li>
<li><p>compatible source-only changes → patch</p>
</li>
</ul>
<p>Merging that PR publishes to npm through trusted publishing with provenance, not a long-lived npm token sitting in CI.</p>
<p>A separate gate runs first: clean build, boundary audit, package creation, anonymous install smoke test. The Pro CLI installer is versioned and released independently of Pro content — a new Pro component doesn't force a CLI bump.</p>
<h3>Entitlement checks run live</h3>
<p>The CLI's OAuth+PKCE flow fits a public, secretless client — there's no client secret to protect. Its session is stored with owner-only file permissions.</p>
<p>Entitlement is checked on every server request, not once at login. A lapsed subscription stops working immediately, not after some cached grace window.</p>
<h3>The license boundary</h3>
<ul>
<li><p>App code, Community component code, and ThreeUI-authored Community imagery: MIT</p>
</li>
<li><p>Bundled fonts: SIL Open Font License 1.1</p>
</li>
<li><p>Bundled Three.js runtime files: their own MIT license</p>
</li>
<li><p>Remote catalog thumbnails and previews loaded live from threeui.com: not redistributed under the repo's license</p>
</li>
</ul>
<p>The boundary sits between what's checked into the repo and what's fetched live from the site at render time. Everything in the repo — code and imagery alike — ships under MIT; only the live-loaded assets fall outside it.</p>
<h2>4. Things to watch</h2>
<ul>
<li><p><code>node_modules</code> <strong>won't show the full picture.</strong> Pro source arrives through an authenticated CLI download, not an npm dependency. A dependency scan of a Pro-using project won't surface it.</p>
</li>
<li><p><strong>Full-document components assume a hosting layout.</strong> Their default asset URLs mirror the hosted preview site's paths. Plan for that coupling during integration.</p>
</li>
<li><p><strong>Some files in the tree are generated, not source.</strong> <code>public/community-sync-report.json</code>, <code>public/source-code.json</code>, and <code>src/data/shaders.tsx</code> are written by the sync job. A PR editing them directly would likely get overwritten by the next sync — confirm contribution scope with maintainers first.</p>
</li>
<li><p><strong>Activity is light but present.</strong> At last check the repo carried 5 open issues and 4 open pull requests against 22 commits total — enough to gauge maintenance pace, not enough to call it a heavily trafficked issue tracker either way.</p>
</li>
<li><p><strong>Third-party listings here are unverifiable.</strong> 21st.dev claims ThreeUI publishes an official shadcn registry at threeui.com, but its own component count for that listing swung between 109, 44, and 33 across fetches minutes apart. threeui.com renders client-side, so its content isn't independently fetchable either. Treat this channel as unresolved, not confirmed.</p>
</li>
</ul>
<h2>Further reading</h2>
<ul>
<li><p><a href="https://github.com/MengTo/threeui">https://github.com/MengTo/threeui</a></p>
</li>
<li><p><a href="https://threeui.com">https://threeui.com</a></p>
</li>
</ul>
<hr />
<p><em>Published via <a href="https://zyvop.com/threeui-getting-started-walkthrough-architecture-review-cf9sf?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=syndication">ZyVOP</a> — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp; Hashnode in 1 click.</em></p>
]]></content:encoded></item></channel></rss>