<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DevOps Daily</title>
    <description>The latest articles on DEV Community by DevOps Daily (@devopsdaily).</description>
    <link>https://dev.to/devopsdaily</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F382434%2F3b4f7f10-38d4-4f4f-8351-1dcb0c1bdfc7.png</url>
      <title>DEV Community: DevOps Daily</title>
      <link>https://dev.to/devopsdaily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsdaily"/>
    <language>en</language>
    <item>
      <title>What Actually Happens After You Send a Webhook</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 30 Jul 2026 11:21:14 +0000</pubDate>
      <link>https://dev.to/devopsdaily/what-actually-happens-after-you-send-a-webhook-fao</link>
      <guid>https://dev.to/devopsdaily/what-actually-happens-after-you-send-a-webhook-fao</guid>
      <description>&lt;p&gt;The first version of a webhook is always the same four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. You ship it. Then the tickets start.&lt;/p&gt;

&lt;p&gt;A customer's endpoint was down during a deploy and they want the events from that window. Another integration processed the same order twice. Someone asks how they can prove the request came from you. Six hours after an incident, a customer wants to know exactly when you attempted event &lt;code&gt;evt_8813&lt;/code&gt; and what their server returned.&lt;/p&gt;

&lt;p&gt;The HTTP call is the easy part. Delivery is the queue, retry policy, signature scheme, idempotency story, and attempt log around it.&lt;/p&gt;

&lt;p&gt;Those failure paths are difficult to learn from a finished code sample, so we built an interactive &lt;strong&gt;&lt;a href="https://devops-daily.com/games/webhook-delivery-simulator" rel="noopener noreferrer"&gt;Webhook Delivery Simulator&lt;/a&gt;&lt;/strong&gt;. It lets you break a delivery on purpose, follow every attempt, inspect the signed bytes, and redeliver the same message to see what a safe receiver does.&lt;/p&gt;

&lt;p&gt;The delivery outcomes are simulated. The HMAC-SHA256 signing and verification are real and run locally in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A webhook sender is a durable state machine, not an HTTP client with a retry loop.&lt;/li&gt;
&lt;li&gt;Retry timeouts, &lt;code&gt;429&lt;/code&gt;, and &lt;code&gt;5xx&lt;/code&gt; responses. Most other &lt;code&gt;4xx&lt;/code&gt; responses should stop immediately.&lt;/li&gt;
&lt;li&gt;A timeout means the outcome is unknown. The receiver may have completed the work before its response was lost.&lt;/li&gt;
&lt;li&gt;At-least-once delivery makes receiver-side deduplication mandatory.&lt;/li&gt;
&lt;li&gt;Sign the message ID, timestamp, and &lt;strong&gt;raw body&lt;/strong&gt;. Verify before parsing.&lt;/li&gt;
&lt;li&gt;The simulator uses the published &lt;a href="https://www.svix.com/?ref=devops-daily" rel="noopener noreferrer"&gt;Svix&lt;/a&gt; retry schedule and Standard Webhooks signing model so the behavior maps to a concrete production system.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with the failure that retries are for
&lt;/h2&gt;

&lt;p&gt;Open the &lt;strong&gt;&lt;a href="https://devops-daily.com/games/webhook-delivery-simulator" rel="noopener noreferrer"&gt;simulator&lt;/a&gt;&lt;/strong&gt;, leave the endpoint response set to &lt;strong&gt;Intermittent&lt;/strong&gt;, and send &lt;code&gt;invoice.paid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The endpoint returns two temporary failures and then recovers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attempt 1: 503 -&amp;gt; retry in 5 seconds
Attempt 2: 503 -&amp;gt; retry in 5 minutes
Attempt 3: 200 -&amp;gt; delivered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the shape of a deploy, a brief database outage, or a process restarting at the wrong moment. Trying again helps because the endpoint is broken &lt;em&gt;now&lt;/em&gt;, not permanently.&lt;/p&gt;

&lt;p&gt;The simulator compresses the real waits into a few seconds. Its time-warp indicator still shows the actual gap being skipped, and every attempt keeps its own status, scheduled time, duration, response body, and explanation.&lt;/p&gt;

&lt;p&gt;That attempt history is not optional operational polish. It is how you answer the customer who asks why an event arrived five minutes late.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry failures, not mistakes
&lt;/h2&gt;

&lt;p&gt;The useful question is not "Did the request fail?" It is "Could the same request plausibly succeed later?"&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;Yes, carefully&lt;/td&gt;
&lt;td&gt;There is no reliable outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Back off and reduce the endpoint's rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;500&lt;/code&gt;, &lt;code&gt;502&lt;/code&gt;, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The server may recover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;408 Request Timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The server explicitly asks for another attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;400&lt;/code&gt;, &lt;code&gt;422&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;The payload is still wrong on the next attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;401&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Retrying cannot repair credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;404&lt;/code&gt;, &lt;code&gt;410&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;The endpoint is gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Try the &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;, and timeout modes. They all produce a non-successful first attempt, but they should not produce the same next state.&lt;/p&gt;

&lt;p&gt;Hammering a malformed request for 27 hours does not make it valid. It turns a customer's configuration error into your outbound traffic problem and buries useful failures in the delivery log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the retry gaps get large
&lt;/h2&gt;

&lt;p&gt;Fixed retries are cheap to write and expensive to operate. Retrying every 30 seconds for an hour produces 120 attempts against an endpoint that may already be struggling to recover.&lt;/p&gt;

&lt;p&gt;The simulator uses &lt;a href="https://docs.svix.com/retries?ref=devops-daily" rel="noopener noreferrer"&gt;Svix's published retry schedule&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1   immediately
attempt 2   +5 seconds
attempt 3   +5 minutes
attempt 4   +30 minutes
attempt 5   +2 hours
attempt 6   +5 hours
attempt 7   +10 hours
attempt 8   +10 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final attempt lands roughly 27 hours and 35 minutes after the first.&lt;/p&gt;

&lt;p&gt;The exact numbers are less important than the shape. Try quickly enough to ride out a small network blip, then spread later attempts out so a long outage costs a handful of requests rather than thousands. A production sender also adds jitter and per-endpoint rate limits so thousands of delayed messages do not wake up together.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout does not mean the work failed
&lt;/h2&gt;

&lt;p&gt;Timeout is the case that makes webhook delivery genuinely awkward.&lt;/p&gt;

&lt;p&gt;Consider this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The receiver accepts the request.&lt;/li&gt;
&lt;li&gt;It updates its database.&lt;/li&gt;
&lt;li&gt;Its response is lost, or arrives after your timeout.&lt;/li&gt;
&lt;li&gt;Your sender schedules another attempt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The receiver completed the work, but the sender cannot know that. If you retry, the receiver sees the message twice. If you do not retry, you might silently lose an event. There is no third choice that avoids both risks.&lt;/p&gt;

&lt;p&gt;The normal answer is &lt;strong&gt;at-least-once delivery&lt;/strong&gt;: retry the unknown outcome and make repeated processing safe.&lt;/p&gt;

&lt;p&gt;A stable message ID must survive every retry and manual redelivery. The receiver stores that ID in the same transaction as the business change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Seen already. Return 200 so retries stop.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyBusinessChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing the dedup row before the work risks losing the event after a crash. Writing it after the work lets two concurrent deliveries pass the check. One transaction closes both gaps.&lt;/p&gt;

&lt;p&gt;After a successful run in the simulator, click &lt;strong&gt;Redeliver same message&lt;/strong&gt;. The message ID stays stable and the receiver acknowledges the duplicate without applying the event twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify bytes before you trust JSON
&lt;/h2&gt;

&lt;p&gt;Webhook signatures prove that the sender knows a shared secret and that the signed content was not changed in transit.&lt;/p&gt;

&lt;p&gt;The simulator follows the &lt;a href="https://www.standardwebhooks.com/" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; format used by Svix. The signature covers the message ID, timestamp, and raw request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signedContent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important word is &lt;strong&gt;raw&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your framework parses the JSON and you serialize it again, whitespace, escaping, or property order can change. The object may mean the same thing while its bytes are different, which correctly produces a different signature.&lt;/p&gt;

&lt;p&gt;A receiver should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the raw body.&lt;/li&gt;
&lt;li&gt;Reject timestamps outside the accepted replay window.&lt;/li&gt;
&lt;li&gt;Verify the HMAC using constant-time comparison.&lt;/li&gt;
&lt;li&gt;Parse the JSON only after verification passes.&lt;/li&gt;
&lt;li&gt;Deduplicate using the stable message ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Open &lt;strong&gt;Verify signature&lt;/strong&gt; in the simulator and try each failure mode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edit body&lt;/strong&gt; changes one value after the request was signed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong secret&lt;/strong&gt; calculates a valid HMAC with the wrong key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay later&lt;/strong&gt; moves verification outside the timestamp tolerance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untouched&lt;/strong&gt; restores the valid request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The simulator shows the signed content and the exact rejection reason rather than reducing every failure to "invalid signature."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Svix fits
&lt;/h2&gt;

&lt;p&gt;The useful thing about &lt;a href="https://www.svix.com/?ref=devops-daily" rel="noopener noreferrer"&gt;Svix Dispatch&lt;/a&gt; is not that it sends an HTTP request. The useful thing is that it packages the operational surface around the request: durable delivery, automatic retries, signing and secret rotation, rate limits, event filtering, searchable attempt logs, manual replay, and a customer-facing endpoint portal.&lt;/p&gt;

&lt;p&gt;Building can still be reasonable when you control every consumer, event volume is low, or you already run a capable job system such as Temporal, Sidekiq, or River.&lt;/p&gt;

&lt;p&gt;The boundary changes when the endpoints belong to customers. At that point, retries are only one part of the product. Customers also need to configure endpoints, rotate secrets, inspect failed attempts, and replay messages without asking an engineer to search production logs.&lt;/p&gt;

&lt;p&gt;A useful test is to write down who answers this question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Did you send event &lt;code&gt;evt_8813&lt;/code&gt;, and what did our endpoint return?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the answer is "an engineer with log access," include that recurring support cost in the build-versus-buy calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-minute simulator walkthrough
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Send &lt;code&gt;invoice.paid&lt;/code&gt; to the &lt;strong&gt;Intermittent&lt;/strong&gt; endpoint.&lt;/li&gt;
&lt;li&gt;Watch the delivery path and compressed retry delays.&lt;/li&gt;
&lt;li&gt;Select each attempt and compare its response and scheduled time.&lt;/li&gt;
&lt;li&gt;Inspect the request headers and raw body.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Verify signature&lt;/strong&gt; and edit the body.&lt;/li&gt;
&lt;li&gt;Restore the untouched request and verify it successfully.&lt;/li&gt;
&lt;li&gt;Redeliver the same message and confirm that the receiver ignores it.&lt;/li&gt;
&lt;li&gt;Repeat with &lt;strong&gt;Timeout&lt;/strong&gt;, then with &lt;strong&gt;400 bad request&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final comparison is the useful one. A timeout and a &lt;code&gt;400&lt;/code&gt; both lack a successful response, but they require opposite decisions: retry the unknown outcome and drop the invalid payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to carry into production
&lt;/h2&gt;

&lt;p&gt;Before calling a webhook sender reliable, check that it has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Durable storage before the first attempt&lt;/li&gt;
&lt;li&gt;Explicit response classification&lt;/li&gt;
&lt;li&gt;Backoff with jitter and a defined give-up point&lt;/li&gt;
&lt;li&gt;Stable message IDs across retries&lt;/li&gt;
&lt;li&gt;HMAC signatures over the raw body&lt;/li&gt;
&lt;li&gt;Timestamp-based replay protection&lt;/li&gt;
&lt;li&gt;Receiver-side deduplication&lt;/li&gt;
&lt;li&gt;Per-endpoint rate limits&lt;/li&gt;
&lt;li&gt;Searchable attempt history&lt;/li&gt;
&lt;li&gt;Manual replay with an audit trail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The POST is the smallest part of the system. Reliability comes from the state and operational controls around it.&lt;/p&gt;

&lt;p&gt;Use the &lt;strong&gt;&lt;a href="https://devops-daily.com/games/webhook-delivery-simulator" rel="noopener noreferrer"&gt;Webhook Delivery Simulator&lt;/a&gt;&lt;/strong&gt; to exercise the failure paths, then read the full &lt;strong&gt;&lt;a href="https://devops-daily.com/posts/reliable-webhook-delivery-retries-signatures-idempotency" rel="noopener noreferrer"&gt;production webhook delivery guide&lt;/a&gt;&lt;/strong&gt; for a typed Svix sender and Express receiver.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>Receiving webhooks without getting burned</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:48:59 +0000</pubDate>
      <link>https://dev.to/devopsdaily/receiving-webhooks-without-getting-burned-5dih</link>
      <guid>https://dev.to/devopsdaily/receiving-webhooks-without-getting-burned-5dih</guid>
      <description>&lt;p&gt;Sending a webhook is easy. You POST some JSON at a URL and move on.&lt;/p&gt;

&lt;p&gt;Receiving one is where the bodies are buried. The endpoint is public, so anyone can call it. It gets retried, so it will run twice. It arrives out of order, so "delivered" can land before "sent". And it is on the critical path of somebody else's system, so if you are slow they will time out and retry, which makes you slower.&lt;/p&gt;

&lt;p&gt;None of this is hard once you know it. All of it is invisible until production. Here is the complete set of things a webhook receiver has to handle, with the failure each one prevents.&lt;/p&gt;

&lt;p&gt;The running example is email delivery webhooks (bounces and complaints), because they happen to have every awkward property at once: they are security-sensitive, they retry, they arrive out of order, and processing one twice corrupts real state. Everything here applies just as well to Stripe, GitHub, Shopify or anything else that calls you back.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Verify the signature, on the raw body
&lt;/h2&gt;

&lt;p&gt;Your endpoint is a public URL. Without verification, anyone who learns it can post a fake "this address hard bounced" event and get a customer suppressed, or a fake "payment succeeded" and get a free subscription.&lt;/p&gt;

&lt;p&gt;Providers sign each request with a shared secret. You recompute the signature and compare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Different lengths mean it cannot match, and timingSafeEqual throws&lt;/span&gt;
  &lt;span class="c1"&gt;// rather than returning false if the lengths differ.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there matter more than they look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;timingSafeEqual&lt;/code&gt;, not &lt;code&gt;===&lt;/code&gt;.&lt;/strong&gt; A normal string comparison returns as soon as it finds a differing byte. An attacker who can measure that timing can recover a valid signature byte by byte. It is a real attack, it is easy to avoid, and the fix is one function call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign the raw body, not the parsed object.&lt;/strong&gt; This is the single most common webhook bug, and it is maddening to debug because everything looks correct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This breaks signature verification. JSON.parse then JSON.stringify is not&lt;/span&gt;
&lt;span class="c1"&gt;// byte-identical to what was sent: key order, whitespace and unicode escaping&lt;/span&gt;
&lt;span class="c1"&gt;// can all change.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Capture the raw bytes for this route before anything parses them.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a Next.js route handler you get this for free, because &lt;code&gt;await req.text()&lt;/code&gt; gives you the untouched body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your framework has body-parsing middleware enabled globally, exempt the webhook path. Every "the signature is valid in curl but fails in my app" question traces back to this.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reject replays
&lt;/h2&gt;

&lt;p&gt;A valid signature proves the payload came from your provider. It does not prove it is happening &lt;em&gt;now&lt;/em&gt;. Someone who captures one request can send it again in a month, signature intact.&lt;/p&gt;

&lt;p&gt;Providers include a timestamp in the signed payload for this. Check it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIVE_MINUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;FIVE_MINUTES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;Math.abs&lt;/code&gt; so clock skew in either direction is handled. Five minutes is the usual tolerance: long enough to survive a slow retry, short enough that a captured request is useless by the time anyone finds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Return 200 immediately, do the work afterwards
&lt;/h2&gt;

&lt;p&gt;Providers give you a short timeout, often 5 to 30 seconds. Go over it and they record a failure and retry. If your handler is slow because it sends an email, updates three tables and calls another API, you will get retried while the first attempt is still running, and now you have two of them.&lt;/p&gt;

&lt;p&gt;Acknowledge first, process after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Durable queue, not a floating promise. If the process dies between the&lt;/span&gt;
  &lt;span class="c1"&gt;// 200 and the work, a background job survives it; a stray async call does not.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook-events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The enqueue has to be durable. &lt;code&gt;processEvent(event)&lt;/code&gt; without an &lt;code&gt;await&lt;/code&gt;, fired off before returning, will silently lose events on deploy, restart or crash, and you will never know which ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Return the right status.&lt;/strong&gt; 200 means "I have it, stop retrying". A 4xx means "this is broken, do not bother retrying". A 5xx means "try me again". Returning 200 on an error you could have recovered from throws the event away permanently.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Assume every event arrives twice
&lt;/h2&gt;

&lt;p&gt;Retries are not an edge case. They happen on timeouts, on deploys, on network blips, and some providers retry on a schedule for hours. Your handler will run twice on the same event, and it must not do the work twice.&lt;/p&gt;

&lt;p&gt;Do not solve this with a "have I seen this?" check in application code. Two concurrent retries will both read "no" before either writes. Let the database decide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processed_webhook_events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doNothing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeTakeFirst&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Somebody else already claimed this id: a retry, or a concurrent delivery.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numInsertedOrUpdatedRows&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doTheActualWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary key does the work. Whichever request inserts first wins, the other returns immediately, and no amount of concurrency changes that.&lt;/p&gt;

&lt;p&gt;If the provider does not send a stable event id, build one from fields that identify the occurrence: the message id plus the event type plus the timestamp. Do not hash the whole payload, because providers add fields over time and your fingerprint would change for what is really the same event.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Events arrive out of order
&lt;/h2&gt;

&lt;p&gt;There is no ordering guarantee. A retried &lt;code&gt;sent&lt;/code&gt; from three minutes ago can land after the &lt;code&gt;delivered&lt;/code&gt; that followed it. Naive handling walks the status backwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: last writer wins, whatever it says.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An email that was delivered, then bounced, then had its &lt;code&gt;sent&lt;/code&gt; retried will finish as "sent". Every dashboard is now wrong.&lt;/p&gt;

&lt;p&gt;Fix it with precedence, not timestamps. Some states are terminal and outrank late arrivals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;complained&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bounced&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a late &lt;code&gt;sent&lt;/code&gt; cannot overwrite &lt;code&gt;bounced&lt;/code&gt;, and the order events happen to arrive in stops mattering. Provider timestamps work too, but they come from someone else's clock, and precedence encodes what you actually mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Fail loudly, and keep what you could not process
&lt;/h2&gt;

&lt;p&gt;Events you reject are gone. The provider retries a few times and gives up, and by then nobody is looking. Store what you could not handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedWebhookEvents&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 5xx so the provider retries as well&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two reasons this earns its place. You can replay after fixing the bug, and a sudden pile of rows in that table is the clearest possible alert that something changed on their side.&lt;/p&gt;

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

&lt;p&gt;You cannot receive a webhook on &lt;code&gt;localhost&lt;/code&gt; from the internet, so use a tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cloudflared, ngrok or similar: whatever puts a public URL on your local port&lt;/span&gt;
cloudflared tunnel &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the printed URL as your webhook endpoint in the provider's dashboard and trigger a real event.&lt;/p&gt;

&lt;p&gt;For the tests that matter, skip the network entirely. Signature verification, idempotency and ordering are pure functions of the payload, so test them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ignores a replayed event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evt_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;m1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the retry&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;countSuppressions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;m1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;does not let a late sent overwrite a bounce&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nextStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These run in milliseconds, need no tunnel, and cover the failures that actually happen in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Verify the signature on the &lt;strong&gt;raw&lt;/strong&gt; body, with a constant-time compare.&lt;/li&gt;
&lt;li&gt;Reject events outside a few minutes of now.&lt;/li&gt;
&lt;li&gt;Return 200 fast, do the work in a durable queue.&lt;/li&gt;
&lt;li&gt;Deduplicate on the provider's event id, enforced by a unique constraint.&lt;/li&gt;
&lt;li&gt;Rank your states so late events cannot walk them backwards.&lt;/li&gt;
&lt;li&gt;Persist failures so you can replay them and notice them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most providers document their half of this well. &lt;a href="https://smtpfa.st/docs/webhooks" rel="noopener noreferrer"&gt;SMTPfast's delivery webhooks&lt;/a&gt; send a signed payload with a stable event id per delivery, bounce and complaint, which is what makes points 1 and 4 straightforward, and it is worth checking your provider gives you both before you build against them. If they do not send a stable event id, you have to synthesise one, and that is a good thing to find out on day one rather than after your first duplicate.&lt;/p&gt;

&lt;p&gt;The pattern is the same everywhere: an endpoint anyone can call, that runs more than once, in an order you do not control. Build for that from the start and webhooks are boring. Discover it in production and they are the thing that quietly corrupted a week of data.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>backend</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Build a Terraform provider for your side project's API (it's smaller than you think)</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 23 Jul 2026 19:56:52 +0000</pubDate>
      <link>https://dev.to/devopsdaily/build-a-terraform-provider-for-your-side-projects-api-its-smaller-than-you-think-5bg8</link>
      <guid>https://dev.to/devopsdaily/build-a-terraform-provider-for-your-side-projects-api-its-smaller-than-you-think-5bg8</guid>
      <description>&lt;p&gt;Writing a Terraform provider sounds like a big-company activity, something HashiCorp partners do with a team and a roadmap. In practice, if your API has CRUD endpoints, the provider is mostly plumbing. Here is the map worth having before you start.&lt;/p&gt;

&lt;p&gt;The running example throughout is the open-source Terraform provider for &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt;, a transactional email API. It is a small, complete provider you can read end to end as a reference:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/smtpfast/terraform-provider-smtpfast" rel="noopener noreferrer"&gt;https://github.com/smtpfast/terraform-provider-smtpfast&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Because your users' infrastructure already lives in Terraform. If creating a domain or an API key in your product requires clicking a dashboard, your product is the one manual step in their otherwise automated stack. A provider turns your product into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"smtpfast_domain"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mail.example.com"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"smtpfast_api_key"&lt;/span&gt; &lt;span class="s2"&gt;"ci"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ci-sender"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real workflow now: domains and keys created in code review, not in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack in 2026: Plugin Framework
&lt;/h2&gt;

&lt;p&gt;Use the &lt;strong&gt;Terraform Plugin Framework&lt;/strong&gt; (&lt;code&gt;terraform-plugin-framework&lt;/code&gt;), not the older SDKv2. It is the maintained path, strongly typed, and much less magical. A provider is a Go module with three kinds of pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The provider type&lt;/strong&gt;: configuration (API endpoint, API key) and client setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources&lt;/strong&gt;: things Terraform creates, updates, and deletes. Each one implements &lt;code&gt;Create&lt;/code&gt;, &lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Update&lt;/code&gt;, &lt;code&gt;Delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data sources&lt;/strong&gt;: read-only lookups.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The skeleton for a resource looks like this (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;domainResource&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;apiClient&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;domainResource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="n"&gt;domainModel&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Diagnostics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValueString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Diagnostics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Diagnostics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Read&lt;/code&gt; fetches current state, &lt;code&gt;Delete&lt;/code&gt; calls the delete endpoint, &lt;code&gt;Update&lt;/code&gt; is often empty for immutable resources. If you have written an API client before, none of this is new; the framework just dictates where each piece lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that actually take time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;State drift is the whole game.&lt;/strong&gt; Terraform's job is comparing desired state to real state, and your &lt;code&gt;Read&lt;/code&gt; function is where reality comes from. Get lazy there (returning stale fields, ignoring server-side changes) and users get mysterious diffs on every plan. Rule of thumb: &lt;code&gt;Read&lt;/code&gt; should map every attribute you declared, from the API response, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive values need care.&lt;/strong&gt; Some APIs return an API key only once, at creation (SMTPfast's does). That means the provider stores the secret in state at create time and must not try to re-read it later. Mark it &lt;code&gt;Sensitive: true&lt;/code&gt; in the schema and document that state files must be treated as secrets (they always should be anyway).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Import support is what makes it adoptable.&lt;/strong&gt; Users have existing domains. &lt;code&gt;terraform import&lt;/code&gt; support (implementing &lt;code&gt;ImportState&lt;/code&gt;) lets them adopt the provider without recreating anything. Skipping it is the difference between a toy and a tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acceptance tests hit the real API.&lt;/strong&gt; The framework's acceptance test harness (&lt;code&gt;TF_ACC=1&lt;/code&gt;) spins resources up and down against a live account. Budget for a test tenant, because these tests create and destroy real things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing to the registry
&lt;/h2&gt;

&lt;p&gt;This part is more checklist than code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Repo named &lt;code&gt;terraform-provider-&amp;lt;name&amp;gt;&lt;/code&gt;, tagged releases with semver (&lt;code&gt;v0.1.0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Release binaries built by GoReleaser for the usual OS/arch matrix.&lt;/li&gt;
&lt;li&gt;Sign the release with a GPG key.&lt;/li&gt;
&lt;li&gt;Publish the GPG public key on the Terraform Registry, then add the provider from your GitHub repo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that, &lt;code&gt;terraform init&lt;/code&gt; fetches the provider like any other, and users write &lt;code&gt;source = "smtpfast/smtpfast"&lt;/code&gt; in their &lt;code&gt;required_providers&lt;/code&gt; block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it worth it?
&lt;/h2&gt;

&lt;p&gt;For the effort (roughly a weekend for v0 with two resources and a data source), unreasonably yes. It closes a real gap for users who automate everything, and writing the provider tends to surface API inconsistencies you did not know you had (drift makes a sloppy API visible). Infrastructure-as-code users are exactly the audience a developer API wants.&lt;/p&gt;

&lt;p&gt;If your side project has an API, put a provider on the list. Start with your two most-created resources, implement Read properly, ship import support, and grow from there. The &lt;a href="https://github.com/smtpfast/terraform-provider-smtpfast" rel="noopener noreferrer"&gt;SMTPfast provider repo&lt;/a&gt; is MIT and small enough to read in one sitting; steal the structure.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>terraform</category>
      <category>go</category>
      <category>smtp</category>
    </item>
    <item>
      <title>Send an Email by Hand: The Raw SMTP Conversation (and Why You Should Not Do It in Production)</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 23 Jul 2026 10:21:43 +0000</pubDate>
      <link>https://dev.to/devopsdaily/send-an-email-by-hand-the-raw-smtp-conversation-and-why-you-should-not-do-it-in-production-1l7f</link>
      <guid>https://dev.to/devopsdaily/send-an-email-by-hand-the-raw-smtp-conversation-and-why-you-should-not-do-it-in-production-1l7f</guid>
      <description>&lt;p&gt;Every email your application sends is, underneath the library and the API, a short text conversation between two servers. You can have that conversation yourself: open a socket to a mail server, type a handful of commands, and a real message lands in a real inbox. Doing it once, by hand, teaches you more about email than any amount of reading, because it shows you exactly what your &lt;code&gt;send()&lt;/code&gt; call is doing on your behalf.&lt;/p&gt;

&lt;p&gt;This post walks the whole SMTP conversation one command at a time, then explains the harder truth: the reason nobody sends production email this way. The gap between "I typed the commands and it worked" and "millions of messages reach the inbox every day" is where retries, encryption, authentication, DKIM, suppression, and sender reputation live. Understanding the raw protocol is exactly what makes those production concerns make sense.&lt;/p&gt;

&lt;p&gt;If you would rather watch the flow than type it, our &lt;a href="https://dev.to/games/smtp-flow-simulator"&gt;SMTP Flow Simulator&lt;/a&gt; animates the same conversation, from app submission through TLS, auth, DNS checks, the recipient MX relay, retries, and bounces. Keep it open in a tab as you read.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SMTP is a line-based text protocol. The client types commands (&lt;code&gt;EHLO&lt;/code&gt;, &lt;code&gt;MAIL FROM&lt;/code&gt;, &lt;code&gt;RCPT TO&lt;/code&gt;, &lt;code&gt;DATA&lt;/code&gt;); the server answers with 3-digit codes (&lt;code&gt;220&lt;/code&gt;, &lt;code&gt;250&lt;/code&gt;, &lt;code&gt;354&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;You can send a real email by hand with &lt;code&gt;telnet&lt;/code&gt; or &lt;code&gt;openssl s_client&lt;/code&gt;. It works, and it is the single best way to understand the protocol.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;envelope&lt;/strong&gt; (&lt;code&gt;MAIL FROM&lt;/code&gt; / &lt;code&gt;RCPT TO&lt;/code&gt;) is separate from the &lt;strong&gt;headers&lt;/strong&gt; (&lt;code&gt;From:&lt;/code&gt; / &lt;code&gt;To:&lt;/code&gt; inside &lt;code&gt;DATA&lt;/code&gt;). That split is why spoofing is easy and why SPF, DKIM, and DMARC exist.&lt;/li&gt;
&lt;li&gt;Production sending needs everything the raw conversation does not give you: TLS everywhere, authentication, DKIM signing, connection reuse, retry-with-backoff, bounce and complaint handling, suppression lists, and IP/domain reputation.&lt;/li&gt;
&lt;li&gt;Once you have seen the protocol, an API like &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; stops being a black box: it is the raw conversation plus every production concern handled for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A terminal with &lt;code&gt;telnet&lt;/code&gt; and &lt;code&gt;openssl&lt;/code&gt; (both ship on macOS and most Linux distros).&lt;/li&gt;
&lt;li&gt;A rough idea of TCP ports and DNS. You do not need to know SMTP yet, that is the point.&lt;/li&gt;
&lt;li&gt;A domain you control if you want to test authenticated sending. Sending &lt;em&gt;to&lt;/em&gt; your own address is the safe way to experiment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The conversation, one command at a time
&lt;/h2&gt;

&lt;p&gt;SMTP runs on a few well-known ports: &lt;code&gt;25&lt;/code&gt; (server-to-server relay), &lt;code&gt;465&lt;/code&gt; (implicit TLS submission), and &lt;code&gt;587&lt;/code&gt; (submission with &lt;code&gt;STARTTLS&lt;/code&gt;). As a client submitting mail, you want &lt;code&gt;587&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every exchange follows the same rhythm: you send a line, the server replies with a 3-digit status code and some text. &lt;code&gt;2xx&lt;/code&gt; means success, &lt;code&gt;3xx&lt;/code&gt; means "keep going, send more", &lt;code&gt;4xx&lt;/code&gt; is a temporary failure (try again later), and &lt;code&gt;5xx&lt;/code&gt; is permanent (do not retry).&lt;/p&gt;

&lt;p&gt;Here is the opening. Connect to port 25 of a mail server and say hello with &lt;code&gt;EHLO&lt;/code&gt; (the extended HELO), which asks the server to list what it supports:&lt;/p&gt;

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

&lt;p&gt;That &lt;code&gt;250-&lt;/code&gt; block is the server advertising what it can do: it supports &lt;code&gt;STARTTLS&lt;/code&gt; (upgrade the connection to encrypted), &lt;code&gt;AUTH&lt;/code&gt; (log in), a max message &lt;code&gt;SIZE&lt;/code&gt;, and &lt;code&gt;8BITMIME&lt;/code&gt;. The last line uses &lt;code&gt;250&lt;/code&gt; (space, not dash) to signal the end of the list.&lt;/p&gt;

&lt;p&gt;Notice what the server told us: it offers &lt;code&gt;STARTTLS&lt;/code&gt;, so right now we are talking in &lt;strong&gt;plaintext&lt;/strong&gt;. Anything we send, including a password, is readable on the wire. So before authenticating, we upgrade.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never send &lt;code&gt;AUTH&lt;/code&gt; credentials over an un-upgraded connection. If a server lets you authenticate in plaintext on port 25, that is a red flag, not a convenience. Always &lt;code&gt;STARTTLS&lt;/code&gt; (or connect to the implicit-TLS port 465) before &lt;code&gt;AUTH&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Encrypt, authenticate, and send
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;STARTTLS&lt;/code&gt;, the connection becomes TLS-encrypted and the plaintext &lt;code&gt;telnet&lt;/code&gt; can no longer read it. The practical way to do the encrypted half by hand is &lt;code&gt;openssl s_client&lt;/code&gt;, which performs &lt;code&gt;STARTTLS&lt;/code&gt; for you and then drops you into the now-secure session:&lt;/p&gt;

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

&lt;p&gt;That &lt;code&gt;250 Ok: queued as 4F1a2b3c&lt;/code&gt; is the moment the server accepts responsibility for your message. You just sent an email with your bare hands.&lt;/p&gt;

&lt;p&gt;Here is the whole handshake as a flow. Open the &lt;a href="https://dev.to/games/smtp-flow-simulator"&gt;simulator&lt;/a&gt; alongside it to watch the same steps animate, including what happens &lt;em&gt;after&lt;/em&gt; the queue (DNS lookups, the recipient's MX, retries, and inbox placement):&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The one detail that explains a decade of email security
&lt;/h2&gt;

&lt;p&gt;Look again at two different places the sender address appeared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the &lt;strong&gt;envelope&lt;/strong&gt;: &lt;code&gt;MAIL FROM:&amp;lt;you@example.com&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;headers&lt;/strong&gt;, inside &lt;code&gt;DATA&lt;/code&gt;: &lt;code&gt;From: You &amp;lt;you@example.com&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are two independent fields, and nothing in SMTP forces them to match. The envelope &lt;code&gt;MAIL FROM&lt;/code&gt; is what the receiving server uses for routing and bounce returns; the header &lt;code&gt;From:&lt;/code&gt; is what the recipient sees in their mail client. You can put anything you like in either.&lt;/p&gt;

&lt;p&gt;That single design fact is why email spoofing is trivial and why the entire modern anti-abuse stack exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt; checks whether the sending IP is allowed to use the envelope &lt;code&gt;MAIL FROM&lt;/code&gt; domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt; cryptographically signs the message so a receiver can verify the header &lt;code&gt;From:&lt;/code&gt; domain really authorized it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt; ties the two together and tells receivers what to do when they disagree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot understand why deliverability is hard until you have seen that the protocol itself will happily let you claim to be anyone. If you want the practical setup for the three records, we walk through them in the &lt;a href="https://dev.to/games/smtp-flow-simulator"&gt;SMTP Flow Simulator&lt;/a&gt;'s DNS-check stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you should not do this in production
&lt;/h2&gt;

&lt;p&gt;Typing the conversation once is enlightening. Building your production sending on top of raw SMTP calls is a mistake, and here is the specific list of what the happy-path telnet session quietly skips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery is not a single request.&lt;/strong&gt; Your &lt;code&gt;250 queued&lt;/code&gt; only means the first hop accepted the message. The receiving server still has to be found (MX lookup), might be down, might greylist you with a &lt;code&gt;4xx&lt;/code&gt; and expect a retry in a few minutes, or might defer under load. Production senders need a real retry queue with exponential backoff that distinguishes &lt;code&gt;4xx&lt;/code&gt; (retry) from &lt;code&gt;5xx&lt;/code&gt; (give up and record a bounce). A shell one-liner does none of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication of the message, not just the connection.&lt;/strong&gt; &lt;code&gt;AUTH LOGIN&lt;/code&gt; proved &lt;em&gt;you&lt;/em&gt; could log in. It did nothing to prove to the &lt;em&gt;recipient&lt;/em&gt; that the message is legitimate. That requires &lt;strong&gt;DKIM signing&lt;/strong&gt; every outgoing message with a private key whose public half lives in your DNS. Get the canonicalization or header selection wrong and signatures fail silently at the receiver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connections are expensive and rate-limited.&lt;/strong&gt; Opening a fresh TCP + TLS handshake per message is slow and will get you throttled. Real senders pool connections, pipeline commands, and respect per-receiver rate limits (Gmail, Outlook, and Yahoo each have their own).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounces and complaints must feed back.&lt;/strong&gt; When a &lt;code&gt;5xx&lt;/code&gt; bounce or a spam complaint (via a feedback loop) comes in, you must stop mailing that address, immediately. Keep hitting dead addresses and mailbox providers read it as spammer behavior and start filtering everything you send. This means maintaining a &lt;strong&gt;suppression list&lt;/strong&gt; and honoring it on every send.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reputation is earned slowly and lost fast.&lt;/strong&gt; Mailbox providers score the IP and domain you send from. New senders must warm up gradually; a sudden spike from a cold IP looks like a compromised account. One bad campaign, or one afternoon of retrying dead addresses, can tank delivery for weeks.&lt;/p&gt;

&lt;p&gt;None of these are protocol features. They are operational systems you would have to build and run around SMTP. That is the actual product an email platform sells.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48n32ojl5ozks4owalxs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48n32ojl5ozks4owalxs.png" alt="SMTP - What lives above the raw protocol" width="800" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two production paths (and where each fits)
&lt;/h2&gt;

&lt;p&gt;Once you have decided not to hand-roll SMTP, you have two real options, and they are not mutually exclusive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Keep speaking SMTP, but let something else manage it.&lt;/strong&gt; Your app already knows how to talk SMTP (every language has a client), so the smallest change is to point that client at a service that handles TLS, auth, DKIM, retries, and reputation for you. That is exactly what the &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; SMTP bridge is: you keep your existing &lt;code&gt;nodemailer&lt;/code&gt; / &lt;code&gt;smtplib&lt;/code&gt; / &lt;code&gt;Mail::Sender&lt;/code&gt; code and just change the host, port, and credentials. Everything from the "why not in production" list above becomes someone else's job. This is the path of least resistance for legacy apps and anything that already emits SMTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Send over a REST API.&lt;/strong&gt; If you are writing new code, a JSON &lt;code&gt;POST&lt;/code&gt; is simpler than managing an SMTP client, connection pool, and MIME construction. You hand over the from, to, subject, and body; the platform builds the message, signs it, sends it, retries it, and streams back delivery events. &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; exposes this as a plain REST API (and there is a hosted MCP server if you want an AI agent to send on your behalf).&lt;/p&gt;

&lt;p&gt;The useful way to think about it: the raw conversation you just typed is the &lt;em&gt;floor&lt;/em&gt;. An API is that floor plus the retry queue, the DKIM signer, the suppression list, and the reputation management, all of which you would otherwise build and babysit yourself.&lt;/p&gt;

&lt;p&gt;Raw SMTP (by hand):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EHLO laptop.local
AUTH LOGIN
...
MAIL FROM:&amp;lt;you@example.com&amp;gt;
RCPT TO:&amp;lt;friend@example.net&amp;gt;
DATA
Subject: Sent by hand

hello
.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMTP client (bridge):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// point an existing SMTP client at the bridge&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;smtp.smtpfa.st&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apikey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SMTPFAST_KEY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;you@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;friend@example.net&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://smtpfa.st/api/v1/emails &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SMTPFAST_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"from":"you@example.com","to":"friend@example.net","subject":"hi","text":"hello"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;The SMTP conversation is small enough to type by hand and old enough to have accumulated every workaround the internet ever invented for trust. Sending one message manually is the fastest way to internalize three things: the protocol is just text, the envelope and headers are separate (so the sender is unverified by default), and the &lt;code&gt;250 queued&lt;/code&gt; you get back is the &lt;em&gt;easy&lt;/em&gt; part.&lt;/p&gt;

&lt;p&gt;Everything hard about email, deliverability, authentication, retries, reputation, lives above the protocol, in the operational layer. That is precisely the layer you are choosing to build yourself or hand to a service like &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; when you pick how your app sends mail.&lt;/p&gt;

&lt;p&gt;Go type the conversation once. Then go watch the whole delivery path, retries and bounces included, in the &lt;a href="https://dev.to/games/smtp-flow-simulator"&gt;SMTP Flow Simulator&lt;/a&gt;. After that, &lt;code&gt;send()&lt;/code&gt; will never look like a black box again.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>smtp</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>SPF, DKIM, DMARC: the 15-minute setup that actually passes</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:23:20 +0000</pubDate>
      <link>https://dev.to/devopsdaily/spf-dkim-dmarc-the-15-minute-setup-that-actually-passes-53mf</link>
      <guid>https://dev.to/devopsdaily/spf-dkim-dmarc-the-15-minute-setup-that-actually-passes-53mf</guid>
      <description>&lt;p&gt;Every guide to email authentication starts with a history lesson. Skip it. You are here because your emails land in spam, or a client asked "is DMARC set up?", or you saw &lt;code&gt;dmarc=fail&lt;/code&gt; in a bounced message. Here is the 15-minute version: the exact records, why each one exists in one sentence, and how to verify you got it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-sentence versions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt; lists which servers may send mail claiming to be from your domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt; cryptographically signs each message so receivers can verify it was not altered and really came from you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt; tells receivers what to do when SPF or DKIM fail, and sends you reports about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SPF and DKIM are the mechanisms. DMARC is the policy on top. You need all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPF: one TXT record
&lt;/h2&gt;

&lt;p&gt;At your domain root (or the subdomain you send from), add a TXT record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=spf1 include:_spf.yourprovider.com ~all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the include with whatever your email provider documents. Sending through Amazon SES it is &lt;code&gt;include:amazonses.com&lt;/code&gt;; Google Workspace is &lt;code&gt;include:_spf.google.com&lt;/code&gt;. If you send through several providers, chain the includes in a single record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=spf1 include:amazonses.com include:_spf.google.com ~all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mistake #1: two SPF records.&lt;/strong&gt; SPF allows exactly one TXT record starting with &lt;code&gt;v=spf1&lt;/code&gt; per domain. Two records means SPF returns a permanent error, which is worse than no record at all. Merge them.&lt;/p&gt;

&lt;p&gt;Also know the 10-DNS-lookup limit: every &lt;code&gt;include&lt;/code&gt; costs lookups, and past 10 the check fails. If you have collected includes from years of tools, prune them.&lt;/p&gt;

&lt;p&gt;Verify it with dig:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT yourdomain.com | &lt;span class="nb"&gt;grep &lt;/span&gt;spf1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or use a checker that also counts your lookups, like this free &lt;a href="https://smtpfa.st/tools/spf-checker" rel="noopener noreferrer"&gt;SPF checker&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  DKIM: the CNAMEs your provider gives you
&lt;/h2&gt;

&lt;p&gt;You do not write DKIM records by hand. Your provider generates a key pair, keeps the private key, and gives you DNS records (usually 1 to 3 CNAMEs) that publish the public key. They look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;abc123._domainkey.yourdomain.com  CNAME  abc123.dkim.provider.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add them exactly as given and wait for verification. That is it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake #2: proxying the DKIM CNAMEs.&lt;/strong&gt; If your DNS is behind Cloudflare, those records must be &lt;strong&gt;DNS only&lt;/strong&gt; (grey cloud). Proxied CNAMEs resolve to Cloudflare IPs and DKIM verification never completes. This one costs people days.&lt;/p&gt;

&lt;p&gt;Verify with the selector your provider used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT abc123._domainkey.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a &lt;code&gt;v=DKIM1; k=rsa; p=...&lt;/code&gt; blob. A &lt;a href="https://smtpfa.st/tools/dkim-checker" rel="noopener noreferrer"&gt;DKIM checker&lt;/a&gt; does the same with the parsing done for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  DMARC: start monitoring, then enforce
&lt;/h2&gt;

&lt;p&gt;Add a TXT record at &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;p=none&lt;/code&gt; means "change nothing, just send me aggregate reports about who is sending as my domain." Run in this mode for a couple of weeks and read the reports; you will usually discover a forgotten tool sending as your domain.&lt;/p&gt;

&lt;p&gt;Then enforce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; pct=100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and eventually &lt;code&gt;p=reject&lt;/code&gt;. Enforcement is what actually stops spoofing, and since 2024 Gmail and Yahoo require a DMARC record for bulk senders at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake #3: jumping straight to p=reject.&lt;/strong&gt; If some legitimate system sends unaligned mail (a CRM, a billing tool, an old cron job), &lt;code&gt;p=reject&lt;/code&gt; silently kills those messages. Monitor first, enforce second.&lt;/p&gt;

&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT _dmarc.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or decode the policy in plain English with a &lt;a href="https://smtpfa.st/tools/dmarc-checker" rel="noopener noreferrer"&gt;DMARC analyzer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;One SPF record, correct include, &lt;code&gt;~all&lt;/code&gt; at the end. Check the lookup count.&lt;/li&gt;
&lt;li&gt;Add the provider's DKIM CNAMEs, unproxied. Confirm the selector resolves.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_dmarc&lt;/code&gt; record at &lt;code&gt;p=none&lt;/code&gt; with a rua address. Calendar reminder for two weeks: read reports, move to &lt;code&gt;quarantine&lt;/code&gt;, then &lt;code&gt;reject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send a test email to a Gmail account, open "Show original", and confirm all three lines say PASS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the ground truth. Gmail's "Show original" view shows &lt;code&gt;spf=pass dkim=pass dmarc=pass&lt;/code&gt; right at the top, and it is checking the real thing rather than just the DNS.&lt;/p&gt;

&lt;p&gt;Once these pass, deliverability problems stop being an authentication problem and start being a reputation problem. That is a different article, but you cannot get there without this one.&lt;/p&gt;

&lt;p&gt;While speaking of emails, if you want to learn how SMTP works under the hood, watch a message move from application code to an SMTP relay, through TLS and AUTH, across DNS and recipient MX checks, and finally into a mailbox check out this simulator: &lt;a href="https://devops-daily.com/games/smtp-flow-simulator" rel="noopener noreferrer"&gt;SMTP Flow Simulator&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dns</category>
      <category>devops</category>
      <category>development</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I gave my AI agent the ability to send email</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 20 Jul 2026 19:43:43 +0000</pubDate>
      <link>https://dev.to/devopsdaily/i-gave-my-ai-agent-the-ability-to-send-email-18nc</link>
      <guid>https://dev.to/devopsdaily/i-gave-my-ai-agent-the-ability-to-send-email-18nc</guid>
      <description>&lt;p&gt;Last month I wired Claude up to my email infrastructure. Not "Claude writes an email draft and I paste it somewhere" but the agent checks my domain status, sends the email, and reads back the delivery events, all from the chat.&lt;/p&gt;

&lt;p&gt;The glue that makes this possible is MCP (Model Context Protocol), and the whole setup takes about five minutes. Here is exactly how to do it, plus what surprised me once agents could actually touch production infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;MCP is a small JSON-RPC protocol that lets an AI client (Claude, Cursor, Windsurf, your own agent) call tools exposed by a server. The server describes its tools with JSON schemas, the model picks a tool and fills in the arguments, and the client executes the call.&lt;/p&gt;

&lt;p&gt;The important design decision is where the server runs. A lot of MCP servers are local stdio processes you install per machine. For anything talking to a hosted API, a &lt;strong&gt;hosted MCP server&lt;/strong&gt; is the better shape: nothing to install, your API key is the auth, and every client that speaks HTTP can use it.&lt;/p&gt;

&lt;p&gt;I use &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; for transactional email, which ships a hosted MCP endpoint at &lt;code&gt;https://smtpfa.st/api/mcp&lt;/code&gt;. That is what I will use in the examples, but the pattern applies to any hosted MCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Connect Claude Code
&lt;/h2&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http smtpfast https://smtpfa.st/api/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sf_your_api_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Cursor, it is a snippet in &lt;code&gt;.cursor/mcp.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"smtpfast"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smtpfa.st/api/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer sf_your_api_key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire installation. No npm package, no local process, no version drift between machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: See what the agent can do
&lt;/h2&gt;

&lt;p&gt;MCP servers self-describe. You can poke one with curl to see the tool list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://smtpfa.st/api/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sf_your_api_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SMTPfast server exposes eight tools: &lt;code&gt;send_email&lt;/code&gt;, &lt;code&gt;list_emails&lt;/code&gt;, &lt;code&gt;get_email&lt;/code&gt;, &lt;code&gt;list_domains&lt;/code&gt;, &lt;code&gt;verify_domain&lt;/code&gt;, &lt;code&gt;list_suppressions&lt;/code&gt;, &lt;code&gt;get_analytics&lt;/code&gt;, and &lt;code&gt;list_contacts&lt;/code&gt;. The model reads those schemas and figures out the rest on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Just ask
&lt;/h2&gt;

&lt;p&gt;With the server connected, I can type things like this into Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Check if my domain is verified, then send a test email from &lt;a href="mailto:hello@mydomain.com"&gt;hello@mydomain.com&lt;/a&gt; to my personal address and tell me when it is delivered."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Behind the scenes the agent calls &lt;code&gt;list_domains&lt;/code&gt;, sees the DKIM status, calls &lt;code&gt;send_email&lt;/code&gt;, waits, then calls &lt;code&gt;get_email&lt;/code&gt; to read the delivery events. I watch each tool call go by and approve it. No SDK, no glue code, no copy-pasting message IDs.&lt;/p&gt;

&lt;p&gt;The debugging workflow is where this gets genuinely useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why did the email to &lt;a href="mailto:jane@example.com"&gt;jane@example.com&lt;/a&gt; bounce yesterday?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent pulls the email, reads the bounce event with the SMTP diagnostic code, checks whether the address landed on the suppression list, and explains it in plain language. That used to be five minutes of clicking through a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The approval step matters more than I expected.&lt;/strong&gt; Most MCP clients show you each tool call before it runs. For read tools that feels like friction. For &lt;code&gt;send_email&lt;/code&gt; it is exactly right. I would not connect a send-capable tool to an agent that runs unattended without scoping the key first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Agents are great at chaining, bad at restraint.&lt;/strong&gt; Ask a vague question and the agent will happily call four tools when one would do. Tight tool descriptions in the server matter as much as good prompts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The server is the easy part.&lt;/strong&gt; If your product already has a REST API, an MCP server is mostly a translation layer: tool schema in, API call out. The hard work (auth, rate limits, validation) already exists in the API. That is also why I prefer hosted MCP over stdio: it reuses everything the API already enforces, including that a compromised key can be revoked in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want to reproduce this end to end: grab a free &lt;a href="https://smtpfa.st" rel="noopener noreferrer"&gt;SMTPfast&lt;/a&gt; account (3,000 emails/month, no card), verify a domain, create an API key, and run the &lt;code&gt;claude mcp add&lt;/code&gt; command above. The &lt;a href="https://smtpfa.st/docs/mcp" rel="noopener noreferrer"&gt;MCP docs&lt;/a&gt; cover the tool schemas and a few example prompts.&lt;/p&gt;

&lt;p&gt;And if you are building a dev tool yourself: ship the hosted MCP endpoint. It is a weekend of work and it makes your product usable by every AI agent your customers already run.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>smtp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Gave an AI Agent a Database, Compute, Storage, and Models From One CLI</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:21:45 +0000</pubDate>
      <link>https://dev.to/devopsdaily/i-gave-an-ai-agent-a-database-compute-storage-and-models-from-one-cli-514h</link>
      <guid>https://dev.to/devopsdaily/i-gave-an-ai-agent-a-database-compute-storage-and-models-from-one-cli-514h</guid>
      <description>&lt;p&gt;A working AI agent has an unglamorous shopping list. It needs a database to remember things, somewhere to run that can stream tokens without timing out, object storage for whatever it produces, and access to a model. Assembled the usual way, that is four separate signups: a Postgres host, a compute platform, an S3 bucket, and an OpenAI or Anthropic account, each with its own credential to provision, inject, and rotate per environment.&lt;/p&gt;

&lt;p&gt;Neon's June 2026 platform preview collapses that list. The pitch is that the database, the compute, the storage, and the model gateway all come from one account and branch together. I wanted to know if that was real or a slide, so I built the canonical example end to end: an image-generating agent that takes a prompt, calls a model, stores the result, and indexes it in Postgres. This is the build log, with the real commands and output, and the parts where the preview still shows.&lt;/p&gt;

&lt;p&gt;(Companion repo: &lt;a href="https://github.com/The-DevOps-Daily/neon-ai-agent" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-ai-agent&lt;/a&gt;. Everything below ran against a fresh project created while writing.)&lt;/p&gt;

&lt;h2&gt;
  
  
  One command to scaffold the whole stack
&lt;/h2&gt;

&lt;p&gt;Neon ships starter templates through its CLI. The image agent is one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;neonctl bootstrap ./ai-agent &lt;span class="nt"&gt;--template&lt;/span&gt; ai-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That scaffolds 26 files: a Hono function, a Drizzle schema, a &lt;code&gt;neon.ts&lt;/code&gt; config, and (a nice touch) a &lt;code&gt;.agents/skills/&lt;/code&gt; directory with skill docs for the AI assistant you are probably using to edit the project. Neon bundles agent instructions for its own products, which tells you who this template is aimed at.&lt;/p&gt;

&lt;p&gt;The file that matters is &lt;code&gt;neon.ts&lt;/code&gt;. It is the entire backend declared in one object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neondatabase/config/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;aiGateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;imagegen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI SDK image agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of intent: turn on the AI gateway, give me a bucket called &lt;code&gt;images&lt;/code&gt;, and deploy &lt;code&gt;src/index.ts&lt;/code&gt; as a function. No connection strings, no bucket ARNs, no model API keys. Those get filled in later, automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linking creates the project, deploying creates everything else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;neon link&lt;/code&gt; creates and attaches a Neon project. The new platform features are private preview, so there are two constraints worth stating up front: everything is in AWS &lt;code&gt;us-east-2&lt;/code&gt;, and it only works on projects created inside the preview. Your existing Neon databases do not grow these features in place.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;neon deploy&lt;/code&gt; reads &lt;code&gt;neon.ts&lt;/code&gt; and provisions the declared services. Here is the whole sequence, link through deploy:&lt;/p&gt;

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

&lt;p&gt;That last line is the actual product. Eleven environment variables (the &lt;code&gt;DATABASE_URL&lt;/code&gt;, the S3 access key/secret/endpoint, and the AI gateway token and base URL) all written for me, all scoped to this branch. The four credentials I would normally collect from four dashboards arrived from one &lt;code&gt;deploy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model call: one credential, any provider
&lt;/h2&gt;

&lt;p&gt;The AI Gateway is OpenAI-compatible. Your existing SDK works by changing only the base URL, so the same chat completion against the cheapest catalog model looks like this in whatever you already use:&lt;/p&gt;

&lt;p&gt;curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl "$NEON_AI_GATEWAY_BASE_URL/ai-gateway/mlflow/v1/chat/completions" \
  -H "Authorization: Bearer $NEON_AI_GATEWAY_TOKEN" \
  -d '{"model":"gpt-5-nano","messages":[
        {"role":"user","content":"What is Neon branching?"}]}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from openai import OpenAI

client = OpenAI(base_url=GATEWAY_URL, api_key=GATEWAY_TOKEN)
client.chat.completions.create(
    model="gpt-5-nano",
    messages=[{"role": "user", "content": "What is Neon branching?"}],
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import OpenAI from 'openai';

const client = new OpenAI({ baseURL: GATEWAY_URL, apiKey: GATEWAY_TOKEN });
await client.chat.completions.create({
  model: 'gpt-5-nano',
  messages: [{ role: 'user', content: 'What is Neon branching?' }],
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hitting it once returns exactly what you would expect from the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gpt-5-nano-2025-08-07"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Neon Postgres branching creates lightweight, independent
      clones of a running database that can be developed in isolation..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same token reaches around 25 models across Anthropic, OpenAI, Google, and a few open-source providers. You move between them by changing one &lt;code&gt;model&lt;/code&gt; string. There is no separate OpenAI or Anthropic account in this project. The published prices look like each provider's own list rate, so the gateway reads as pass-through with the convenience of a single credential:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhbe18ntja4wae913kl6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhbe18ntja4wae913kl6t.png" alt="Output price per 1M tokens, a few AI Gateway models" width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The point is not the specific numbers, it is that "use a cheap model in CI and a frontier model in prod" becomes a config value rather than a second vendor integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage that the function can reach with the normal S3 SDK
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;images&lt;/code&gt; bucket is plain S3 as far as your code is concerned. The injected &lt;code&gt;AWS_*&lt;/code&gt; variables point the standard AWS SDK at a branch-scoped endpoint, so this just works inside the function with no custom client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed it directly: a &lt;code&gt;PutObject&lt;/code&gt; then &lt;code&gt;GetObject&lt;/code&gt; round-tripped, and the presigned URL came back on a host scoped to the branch (&lt;code&gt;br-green-star-….storage.c-3.us-east-2.aws.neon.tech&lt;/code&gt;). That branch scoping is the part you cannot get by bolting an external S3 bucket onto a database: open a branch and its files fork with it, so a preview environment never writes into production's objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: the agent runs
&lt;/h2&gt;

&lt;p&gt;The function is a small handler. It streams a model response, and when the model calls its image-generation tool, it uploads the JPEG to the bucket, inserts a row in Postgres, and returns a presigned URL. Calling the deployed agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGEGEN_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"messages":[{"role":"user",
       "content":"Draw a small minimalist server rack icon, flat style"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response streams back as the agent narrates and draws, and afterward the side effects are all there. The object is in the bucket, and the row is in Postgres pointing at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id |              prompt               |             bucket_key              | bytes
----+-----------------------------------+-------------------------------------+-------
  2 | Draw a small minimalist server... | generated/ed49b102-…-f8c46e2f8c16.jpg | 47372
  1 | Draw a small minimalist server... | generated/9125d5b4-…-63b54a892695.jpg | 47372
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From an empty directory to a deployed agent that generates an image, stores it, and indexes it in Postgres took a few minutes and exactly one credential. The model call, the file write, and the database insert were all wired by the platform, not by me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it still shows the preview
&lt;/h2&gt;

&lt;p&gt;The build was smooth, but it is private preview and a few seams are worth knowing before you plan around it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One region, new projects only.&lt;/strong&gt; Everything is in AWS &lt;code&gt;us-east-2&lt;/code&gt; and only works on projects created inside the preview. You cannot bolt these features onto an existing production database today.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Functions are request/response, not a job runner.&lt;/strong&gt; Great for the agent's synchronous loop and streaming; background work (queues, retries, schedules) still belongs to something like Inngest or QStash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two gateway dialects, and it matters.&lt;/strong&gt; The &lt;code&gt;OPENAI_BASE_URL&lt;/code&gt; Neon injects points at the OpenAI &lt;em&gt;Responses&lt;/em&gt; API route. A plain chat-completions call needs the &lt;code&gt;mlflow&lt;/code&gt; dialect route instead. I hit a &lt;code&gt;404&lt;/code&gt; until I switched routes. The SKILL docs the template ships actually explain this, which is the kind of detail that saves you ten minutes if you read it first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing is half-public.&lt;/strong&gt; Per-model token prices are listed, but whether there is a markup or preview credits on top is not spelled out. Fine for a demo, a question to ask before a budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The convenience is also coupling.&lt;/strong&gt; One config file declaring your functions, buckets, and gateway is, by design, Neon-shaped. The S3-compatible API and standard SDKs keep the exit ramps wide, but this is a bet on one vendor for four things you used to buy separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So is it real?
&lt;/h2&gt;

&lt;p&gt;Yes, with an asterisk for "preview." The genuinely useful part is not any single feature, it is that the four pieces an agent needs arrive together, branch together, and authenticate with one credential. If you have ever spent the first afternoon of an AI side project wiring a database to a compute host to an S3 bucket to a model provider, collapsing that into one &lt;code&gt;neon.ts&lt;/code&gt; and one &lt;code&gt;deploy&lt;/code&gt; is a real reduction in moving parts.&lt;/p&gt;

&lt;p&gt;Whether you should build on it today depends on your appetite for a private preview and for vendor consolidation. But as a statement of direction, an agent stack from one CLI is a clear one. We dig into the strategy behind it in &lt;a href="https://devops-daily.com/posts/neon-backend-platform-not-just-postgres" rel="noopener noreferrer"&gt;Neon is becoming a backend platform, not just Postgres&lt;/a&gt;, and we benchmark Neon's database side in the &lt;a href="https://devops-daily.com/posts/neon-vs-supabase-free-tier-benchmarks" rel="noopener noreferrer"&gt;Neon vs Supabase series&lt;/a&gt;. As these features leave preview, we will keep testing them the same way: real projects, real output, and the demo code published so you can run it yourself.&lt;/p&gt;

&lt;p&gt;The full project is on GitHub. Clone it, point &lt;code&gt;neonctl&lt;/code&gt; at a new &lt;code&gt;us-east-2&lt;/code&gt; project, and &lt;code&gt;deploy&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-ai-agent" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-ai-agent&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Realtime Without a WebSocket Service</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:09:58 +0000</pubDate>
      <link>https://dev.to/devopsdaily/realtime-without-a-websocket-service-1gk3</link>
      <guid>https://dev.to/devopsdaily/realtime-without-a-websocket-service-1gk3</guid>
      <description>&lt;p&gt;The moment a feature needs to update live, a live counter, a presence indicator, a "new message" badge, an activity feed, the reflex is to reach for a websocket service. Pusher, Ably, a Socket.IO server, a stateful Node process parked next to your stateless app. That is one more thing to deploy, scale, secure, and pay for, and it exists mostly to move small events from one place to a bunch of connected browsers.&lt;/p&gt;

&lt;p&gt;If your data already lives in Postgres, you already have a message bus for that. Postgres ships with &lt;code&gt;LISTEN&lt;/code&gt; and &lt;code&gt;NOTIFY&lt;/code&gt;, a lightweight publish/subscribe system built into the database. Pair it with server-sent events from a serverless function and you can fan realtime updates out to every connected client without standing up any realtime infrastructure at all. In this post I build exactly that on a Neon Function, explain the one part that is subtle on serverless, and prove it works with two live subscribers. The &lt;a href="https://github.com/The-DevOps-Daily/neon-realtime-demo" rel="noopener noreferrer"&gt;repo&lt;/a&gt; is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postgres &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt; is a built-in pub/sub. &lt;code&gt;NOTIFY channel, 'payload'&lt;/code&gt; delivers to every connection that has run &lt;code&gt;LISTEN channel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A serverless function holds each browser's SSE connection open and keeps one Postgres &lt;code&gt;LISTEN&lt;/code&gt; connection. On a write, the app calls &lt;code&gt;pg_notify&lt;/code&gt;, and every isolate pushes the event to its SSE clients.&lt;/li&gt;
&lt;li&gt;The subtle part on serverless: the runtime runs several isolates, each with its own in-memory set of clients. &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt; is what fans an event across all of them; an in-process broadcast alone would only reach one isolate's clients.&lt;/li&gt;
&lt;li&gt;One real gotcha: &lt;code&gt;LISTEN&lt;/code&gt; needs a session, so it must use a direct (unpooled) connection, not the transaction pooler.&lt;/li&gt;
&lt;li&gt;It is fan-out for small live events, not a durable queue. For guaranteed delivery or bidirectional low-latency you still want a real broker or websockets.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Neon project on the platform preview (Functions, &lt;code&gt;us-east-2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The Neon CLI (&lt;code&gt;npm i -g neon&lt;/code&gt;, then &lt;code&gt;neon login&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Familiarity with Postgres and with SSE / &lt;code&gt;EventSource&lt;/code&gt; on the client&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two pieces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Postgres LISTEN/NOTIFY&lt;/strong&gt; is a pub/sub channel inside the database. A connection subscribes with &lt;code&gt;LISTEN counter_updates&lt;/code&gt;, and any connection (from anywhere) that runs &lt;code&gt;NOTIFY counter_updates, '42'&lt;/code&gt; causes Postgres to deliver that payload to every subscriber. No extra service, no broker to run; it is a feature of the database you already have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-sent events (SSE)&lt;/strong&gt; are the other half. SSE is a long-lived HTTP response that streams &lt;code&gt;data:&lt;/code&gt; frames to the browser, consumed with the built-in &lt;code&gt;EventSource&lt;/code&gt; API. It is one-directional (server to client), which is exactly the shape of most realtime UI: the server has news, the browser wants it. And because it is just an HTTP response, a serverless function can serve it.&lt;/p&gt;

&lt;p&gt;Put them together: the function streams SSE to browsers and relays anything it hears on a Postgres channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is subtle on serverless
&lt;/h2&gt;

&lt;p&gt;Here is the trap. A function under load does not run as one process; the runtime spins up several isolates in parallel. Each isolate has its own memory, so each keeps its own set of open SSE connections. If you only broadcast in-process, a client connected to isolate A never sees an event triggered through isolate B.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt; is what closes that gap. Every isolate opens its own &lt;code&gt;LISTEN&lt;/code&gt; connection to Postgres. When any code anywhere calls &lt;code&gt;NOTIFY&lt;/code&gt;, Postgres delivers it to all of those connections, so every isolate gets the event and pushes it to its own clients. Postgres is the shared fan-out point that the isolates do not otherwise have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcd8emo5tlccs1ijjwbgw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcd8emo5tlccs1ijjwbgw.png" alt="Postgres fans one NOTIFY out to every isolate" width="800" height="303"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One dedicated LISTEN connection per isolate. LISTEN needs a real session,&lt;/span&gt;
&lt;span class="c1"&gt;// so use the DIRECT (unpooled) URL, not the transaction pooler.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseUrlUnpooled&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LISTEN counter_updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// SSE connections held open by THIS isolate.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ReadableStreamDefaultController&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// push to this isolate's browsers&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write path is a normal query plus a &lt;code&gt;NOTIFY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/increment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoUpdate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; + 1`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Fan the new value out to every isolate, and thus every browser.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT pg_notify($1, $2)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counter_updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the SSE endpoint just registers the browser and streams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ReadableStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// send the current value immediately so a new tab is correct on load&lt;/span&gt;
      &lt;span class="nf"&gt;readCount&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="cm"&gt;/* remove this controller from clients */&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;LISTEN&lt;/code&gt; holds a session-level subscription, which the transaction pooler (PgBouncer in transaction mode) does not support. Use the direct, unpooled connection string for the listener (Neon injects it as &lt;code&gt;DATABASE_URL_UNPOOLED&lt;/code&gt;). Keep using the pooled URL for your normal queries. Getting this wrong is the usual reason "notifications never arrive."&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I deployed the counter as a Neon Function and connected two independent SSE subscribers, then fired three increments. Every subscriber should see its starting value on connect and then each new value as it happens. Here is the actual run:&lt;/p&gt;

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

&lt;p&gt;Both streams saw every value. Neither subscriber talked to the other, and there is no websocket server anywhere in this picture; the events traveled browser → function → Postgres &lt;code&gt;NOTIFY&lt;/code&gt; → every function isolate → every browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebSocket service vs LISTEN/NOTIFY + SSE
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Dedicated websocket service&lt;/th&gt;
&lt;th&gt;LISTEN/NOTIFY + SSE on a function&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Extra infrastructure&lt;/td&gt;
&lt;td&gt;A service to run, scale, secure&lt;/td&gt;
&lt;td&gt;None; uses Postgres + the function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Bidirectional&lt;/td&gt;
&lt;td&gt;Server to client (SSE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fan-out bus&lt;/td&gt;
&lt;td&gt;The service&lt;/td&gt;
&lt;td&gt;Postgres &lt;code&gt;NOTIFY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;Often buffered / retried&lt;/td&gt;
&lt;td&gt;Best-effort; dropped if no listener&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Chat, cursors, games, huge fan-out&lt;/td&gt;
&lt;td&gt;Live counters, feeds, notifications, presence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this stops being enough
&lt;/h2&gt;

&lt;p&gt;This pattern is a genuine "delete a service" win for a large class of realtime features, but be honest about its edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not a durable queue.&lt;/strong&gt; &lt;code&gt;NOTIFY&lt;/code&gt; is fire-and-forget. If nobody is listening at that instant, the message is gone. That is fine for a live UI that re-reads state on reconnect; it is not fine for guaranteed delivery or work queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payloads are small.&lt;/strong&gt; Postgres caps a &lt;code&gt;NOTIFY&lt;/code&gt; payload at 8000 bytes. Send an id or a small value and let clients fetch details, rather than shipping large blobs through the channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE is one-way.&lt;/strong&gt; For low-latency bidirectional traffic (multiplayer, live cursors, collaborative editing) a websocket is still the right tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At very high scale&lt;/strong&gt; a dedicated broker earns its keep. This shines at the small-to-medium fan-out that most apps actually need, without the standing infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;The full counter, backend function plus a small web client, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-realtime-demo" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-realtime-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Realtime does not always mean a websocket service. For the common cases, a live number, a badge, a feed, an activity stream, Postgres &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt; is a pub/sub you already run, and SSE from a serverless function is enough to get those events to the browser. On Neon the function lives on the branch next to Postgres, so the listener connection is a local hop and the whole realtime path is one deploy, no separate service to operate. Reach for a real broker or websockets when you need durability or two-way low latency; reach for this when you just want the UI to update and would rather not run another box to make it happen.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Preview Environments That Include the Backend, Not Just the Frontend</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:00:57 +0000</pubDate>
      <link>https://dev.to/devopsdaily/preview-environments-that-include-the-backend-not-just-the-frontend-24cl</link>
      <guid>https://dev.to/devopsdaily/preview-environments-that-include-the-backend-not-just-the-frontend-24cl</guid>
      <description>&lt;p&gt;Open a pull request and your frontend host hands you a preview URL. Vercel, Netlify, Cloudflare Pages all do it: every PR gets its own isolated build you can click through before merging. It is one of the genuinely great DevOps conveniences of the last decade.&lt;/p&gt;

&lt;p&gt;Then you look at what that preview talks to. The API and the database behind it are almost always a single shared staging environment. Every open PR hits the same backend, runs migrations against the same schema, and reads and writes the same rows. So the preview is only half a preview. The frontend is isolated; the thing it depends on is a free-for-all.&lt;/p&gt;

&lt;p&gt;Neon changes what a "branch" contains. A branch is not just a copy of your schema, it is a copy-on-write copy of the data too, and with Neon Functions the compute deploys onto that branch as well. So a branch is the database, its data, and the backend, forked together, each with its own URL. That makes a real per-PR backend cheap enough to create and throw away on every pull request. In this post I show the workflow and prove the isolation with a live function, then sketch how to wire it into CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Frontend previews are isolated per PR. The backend they call usually is not, so previews share one staging database and its migrations and data.&lt;/li&gt;
&lt;li&gt;A Neon branch copies the schema and the data (copy-on-write), and Neon Functions deploy onto the branch, so each branch is a full isolated backend with its own function URL.&lt;/li&gt;
&lt;li&gt;I tested it: branched a live todos API, the branch came up with a copy of main's rows, a write to the branch left main untouched, and the branch had its own URL.&lt;/li&gt;
&lt;li&gt;In CI this is: on PR open, create a branch and deploy the function; hand the frontend preview that branch's URL; on PR close, delete the branch and everything goes with it.&lt;/li&gt;
&lt;li&gt;Because branches are copy-on-write and functions scale to zero, a preview backend costs almost nothing while it sits idle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Neon project on the platform preview (Functions, &lt;code&gt;us-east-2&lt;/code&gt;) with a deployed function&lt;/li&gt;
&lt;li&gt;The Neon CLI (&lt;code&gt;npm i -g neon&lt;/code&gt;, then &lt;code&gt;neon login&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A CI system that can run CLI commands on pull-request events (the example uses GitHub Actions)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why shared staging quietly hurts
&lt;/h2&gt;

&lt;p&gt;A shared staging backend fails in ways that are easy to miss until they bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrations collide.&lt;/strong&gt; Two PRs each add a column, or one renames a table the other still reads. Whoever runs their migration second gets a broken staging environment, and now both previews are wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data bleeds between PRs.&lt;/strong&gt; One PR's test run creates records another PR's preview then displays. Bugs appear and vanish depending on who ran what, and nobody can reproduce them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The preview is not like production.&lt;/strong&gt; To avoid touching real data, staging often runs a thin set of seed fixtures, so the preview never sees the shape or volume of real data and "works in preview" does not mean "works in prod."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resetting is scary.&lt;/strong&gt; Because everyone shares it, nobody wants to be the one who wipes staging, so bad data accumulates for months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a tooling failure on the frontend side. It is that the backend was never actually part of the preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Neon branch gives you
&lt;/h2&gt;

&lt;p&gt;A Neon branch is a copy-on-write fork of the database at a point in time. It starts with the parent's schema and data instantly, without physically copying the bytes, and it diverges only as you write to it. Neon Functions extend that: when you deploy, the function is applied to a branch, and every branch gets its own function URL of the form &lt;code&gt;https://&amp;lt;branch&amp;gt;-&amp;lt;function&amp;gt;.compute.&amp;lt;region&amp;gt;.aws.neon.tech&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Put those together and a branch is a self-contained backend: its own database, its own copy of the data, and its own API endpoint. Nothing it does touches the parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the isolation
&lt;/h2&gt;

&lt;p&gt;I have a small todos API (Hono + Drizzle on a Neon Function) already deployed on &lt;code&gt;main&lt;/code&gt;, with a handful of rows. Here is the whole preview-backend lifecycle against it, with the real output.&lt;/p&gt;

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

&lt;p&gt;That is the whole point in one sequence. The branch came up with its own function URL and a copy of main's four rows, a write landed only on the branch, main stayed at four, and deleting the branch cleaned up the database, the data, and the endpoint in one step. Every number there is from the real run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into CI
&lt;/h2&gt;

&lt;p&gt;The manual commands map directly onto pull-request automation. On open or update, create a branch named after the PR and deploy the function; expose the branch's function URL to your frontend preview as its API base; on close, delete the branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/preview-backend.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;preview-backend&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reopened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;closed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NEON_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NEON_API_KEY }}&lt;/span&gt;
      &lt;span class="na"&gt;BRANCH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-${{ github.event.number }}-preview&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="c1"&gt;# Create-or-update the branch and (re)deploy the function to it.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.action != 'closed'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npx neon branches create --name "$BRANCH" || echo "branch exists"&lt;/span&gt;
          &lt;span class="s"&gt;npx neon deploy --branch "$BRANCH"&lt;/span&gt;
          &lt;span class="s"&gt;# Expose the branch's function URL to the frontend preview, e.g. as&lt;/span&gt;
          &lt;span class="s"&gt;# an env var on the Vercel/Netlify deploy for this PR.&lt;/span&gt;

      &lt;span class="c1"&gt;# Tear it all down when the PR closes.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.action == 'closed'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx neon branches delete "$BRANCH"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the frontend preview and the backend preview live and die together. Reviewers click a preview that is running that PR's real code against that PR's own database, seeded from a real copy of production data, and none of it can affect anyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared staging vs a branch per PR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Shared staging backend&lt;/th&gt;
&lt;th&gt;Branch per PR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Isolation&lt;/td&gt;
&lt;td&gt;One database for all PRs&lt;/td&gt;
&lt;td&gt;Own database + data + URL per PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations&lt;/td&gt;
&lt;td&gt;Collide across PRs&lt;/td&gt;
&lt;td&gt;Run only against that branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data realism&lt;/td&gt;
&lt;td&gt;Thin seed fixtures&lt;/td&gt;
&lt;td&gt;Copy-on-write copy of real data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teardown&lt;/td&gt;
&lt;td&gt;Manual, scary, shared&lt;/td&gt;
&lt;td&gt;Delete the branch, everything goes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle cost&lt;/td&gt;
&lt;td&gt;An always-on staging box&lt;/td&gt;
&lt;td&gt;Copy-on-write storage + scale-to-zero compute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Because a branch is copy-on-write, it does not duplicate your data on disk; it stores only what diverges. Combined with functions that scale to zero when idle, a preview backend for a PR that nobody is actively clicking costs close to nothing, which is what makes one-per-PR practical rather than a budget conversation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;The todos API used here (Hono + Drizzle on a Neon Function) is the same one from the first post in this series:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-functions-demo" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-functions-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Preview environments earned their reputation on the frontend, where every PR gets a clean, clickable, isolated build. The backend got left behind on shared staging, and that is where the confusing bugs and the migration standoffs come from. Because a Neon branch carries the schema, the data, and now the function together, you can give each pull request a real backend of its own and delete it on merge. The frontend preview finally talks to something as disposable and isolated as it is.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Postgres-Backed MCP Server in ~20 Lines</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:46:58 +0000</pubDate>
      <link>https://dev.to/devopsdaily/a-postgres-backed-mcp-server-in-20-lines-590h</link>
      <guid>https://dev.to/devopsdaily/a-postgres-backed-mcp-server-in-20-lines-590h</guid>
      <description>&lt;p&gt;The Model Context Protocol is how an AI agent gets tools. You stand up an MCP server, it advertises a set of tools with typed inputs, and the agent calls them. For a huge number of real MCP servers, those tools are thin wrappers around a database: search these records, create this row, update that field. The server is mostly a translator between JSON-RPC and SQL.&lt;/p&gt;

&lt;p&gt;Which raises an obvious question. If an MCP server spends its life talking to Postgres, why does it so often run somewhere far away from Postgres? The usual setup is an MCP server on one host and the database on another, so every tool call pays a network round trip to reach the data it needs.&lt;/p&gt;

&lt;p&gt;Neon Functions let you skip that. You deploy the MCP server as a function that lives on the same database branch it queries, in the same region, so the server-to-Postgres hop is a local one. In this post I build a Postgres-backed MCP server, deploy it onto a branch, connect a real MCP client, and show what the round trips actually look like. The whole thing is about twenty lines of interesting code, and the &lt;a href="https://github.com/The-DevOps-Daily/neon-mcp-demo" rel="noopener noreferrer"&gt;repo&lt;/a&gt; is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An MCP server that exposes database tools is mostly network plus queries. Running it next to the database removes a cross-region hop from every tool call.&lt;/li&gt;
&lt;li&gt;Neon Functions deploy your MCP server onto a database branch, co-located with Postgres. The server-to-database query is a same-region hop of a millisecond or two, not a transatlantic one.&lt;/li&gt;
&lt;li&gt;The core is small: define a Drizzle schema, register a tool whose handler runs a query, and expose the MCP server over the streamable HTTP transport at &lt;code&gt;/mcp&lt;/code&gt;. That is the ~20 lines.&lt;/li&gt;
&lt;li&gt;Any MCP client that speaks streamable HTTP connects to it: &lt;code&gt;mcporter&lt;/code&gt;, the MCP SDK, or an agent like Claude or Cursor pointed at the URL.&lt;/li&gt;
&lt;li&gt;Each branch gets its own function URL, so every preview or test branch can have its own isolated MCP endpoint over its own copy of the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 20+ and the Neon CLI (&lt;code&gt;npm i -g neon&lt;/code&gt;, then &lt;code&gt;neon login&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A Neon account with the platform preview enabled (Functions, new &lt;code&gt;us-east-2&lt;/code&gt; projects)&lt;/li&gt;
&lt;li&gt;Basic familiarity with Postgres and TypeScript&lt;/li&gt;
&lt;li&gt;Optional: an MCP client to point at it, such as &lt;code&gt;mcporter&lt;/code&gt;, Claude, or Cursor&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What an MCP server actually is
&lt;/h2&gt;

&lt;p&gt;Strip away the branding and an MCP server is a small RPC service. It speaks JSON-RPC over a transport, and it advertises a list of tools. Each tool has a name, a description, and an input schema. When the agent decides to call a tool, the server runs a handler and returns a result. That is the whole contract.&lt;/p&gt;

&lt;p&gt;The transport here is streamable HTTP: the client POSTs JSON-RPC messages to a single endpoint (&lt;code&gt;/mcp&lt;/code&gt;) and reads responses back, with server-sent events for anything streamed. It works over plain HTTPS, which is exactly what a serverless function serves, so an MCP server and a Neon Function are a natural fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ~20 lines
&lt;/h2&gt;

&lt;p&gt;Here is the core of a Postgres-backed MCP server. A schema, one tool whose handler runs a query, and the wiring to expose it over streamable HTTP. Everything else is more of the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;drizzle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm/node-postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ilike&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StreamableHTTPTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@hono/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db/schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One pool per isolate, reused across requests.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Search contacts by name. Omit the query to list everyone.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;substring to match&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;ilike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`%&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Expose the server over streamable HTTP at /mcp.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hono&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamableHTTPTransport&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isConnected&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool handler is the interesting part. It is just a query. &lt;code&gt;registerTool&lt;/code&gt; gives the agent the name, the description, and a Zod input schema (the SDK turns that into the JSON schema the model sees), and your handler returns content. The &lt;a href="https://github.com/The-DevOps-Daily/neon-mcp-demo" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; fills this out to full CRUD (&lt;code&gt;create_contact&lt;/code&gt;, &lt;code&gt;update_contact&lt;/code&gt;, &lt;code&gt;delete_contact&lt;/code&gt;, &lt;code&gt;search_contacts&lt;/code&gt;) against a small &lt;code&gt;contacts&lt;/code&gt; table, but every tool follows this same shape: describe it, run a query, return the rows.&lt;/p&gt;

&lt;p&gt;The schema is ordinary Drizzle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm/pg-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;defaultNow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the function declaration that tells Neon what to deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// neon.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neon/config/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contacts mcp server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploy it onto the branch
&lt;/h2&gt;

&lt;p&gt;The Neon CLI scaffolds the template, links (or creates) a project, pushes the schema, and deploys the function. From an empty directory:&lt;/p&gt;

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

&lt;p&gt;That last URL is the deployed MCP server. The function and the Postgres branch it queries are in the same region, &lt;code&gt;us-east-2&lt;/code&gt;. The MCP endpoint is that URL plus &lt;code&gt;/mcp&lt;/code&gt;. If you want to iterate before deploying, &lt;code&gt;neon dev&lt;/code&gt; serves the same function locally at &lt;code&gt;http://localhost:8787&lt;/code&gt; with the MCP endpoint at &lt;code&gt;/mcp&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A Neon Function has a &lt;strong&gt;public HTTPS URL, reachable by anyone who has it.&lt;/strong&gt; This example runs open for the demo, which is not acceptable for anything real: these tools read and write your database. Gate the endpoint before you share the URL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The gate is a few lines of Hono middleware in front of &lt;code&gt;/mcp&lt;/code&gt;. The repo ships it env-gated: leave &lt;code&gt;MCP_TOKEN&lt;/code&gt; unset and the demo stays open, set it and every request needs the bearer token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most MCP clients can send custom headers, so the agent side is one config line (&lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;). I verified the gate directly against the app: no header and a wrong token both get a 401, the right token passes through to the transport, and with &lt;code&gt;MCP_TOKEN&lt;/code&gt; unset the endpoint behaves exactly as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire up a client and watch it work
&lt;/h2&gt;

&lt;p&gt;Any MCP client that speaks streamable HTTP can connect to &lt;code&gt;/mcp&lt;/code&gt;. Here are three ways: a CLI, the SDK, and adding it to an agent.&lt;/p&gt;

&lt;p&gt;mcporter (CLI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# List the tools the server advertises
mcporter list https://&amp;lt;branch&amp;gt;-contacts.compute.c-3.us-east-2.aws.neon.tech/mcp --schema

# Call a tool
mcporter call ".../mcp.create_contact" name="Ada Lovelace" company="Analytical Engines"
mcporter call ".../mcp.search_contacts" query="engine"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MCP SDK (Node):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const url = new URL('https://&amp;lt;branch&amp;gt;-contacts.compute.c-3.us-east-2.aws.neon.tech/mcp');
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(url));

console.log((await client.listTools()).tools.map((t) =&amp;gt; t.name));
const r = await client.callTool({ name: 'search_contacts', arguments: { query: 'ada' } });
console.log(r.content[0].text);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude / Cursor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Point an MCP-aware agent at the URL as a streamable HTTP server.
# add-mcp writes the client config for you:
npx add-mcp https://&amp;lt;branch&amp;gt;-contacts.compute.c-3.us-east-2.aws.neon.tech/mcp -a claude

# Then in the agent: "search my contacts for anyone at the Navy"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran the SDK client against the deployed server from a machine in Europe. The handshake and the tool calls all worked on the first try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect (initialize + handshake): ~1.5 s   (cold start ~2 s the first time)
tools/list: create_contact, update_contact, delete_contact, search_contacts
create_contact: 196 ms  -&amp;gt;  { "created": { "id": 1, "name": "Ada Lovelace", ... } }
search_contacts "navy": 150 ms  -&amp;gt;  { "count": 1, "contacts": [ { "name": "Grace Hopper", ... } ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A direct &lt;code&gt;SELECT count(*)&lt;/code&gt; against the branch afterwards showed the rows really landed in Postgres. Nothing is held in memory; the tools are just queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why co-location is the point
&lt;/h2&gt;

&lt;p&gt;Those tool-call numbers are around 150 to 200 milliseconds, but that is a measurement of my distance to the function, not the function's speed. I am in Europe and the function is in &lt;code&gt;us-east-2&lt;/code&gt;, so each call is roughly one transatlantic round trip. An agent running near the region, or the model provider's own infrastructure calling the tool, sees a small fraction of that.&lt;/p&gt;

&lt;p&gt;The number that does not move with the client's location is the hop from the function to Postgres, and that is the one co-location fixes. In the &lt;a href="https://devops-daily.com/posts/neon-functions-compute-on-your-database-branch" rel="noopener noreferrer"&gt;first post in this series&lt;/a&gt; I measured exactly that: a &lt;code&gt;SELECT&lt;/code&gt; from inside the function against the co-located branch ran in about 1.2 ms, versus about 135 ms for the same query issued across the Atlantic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j4713wyonkkab3a3s0s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j4713wyonkkab3a3s0s.png" alt="The hop that a database-backed MCP server actually spends its time on" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A tool call that runs one or two queries inherits that difference on every invocation. Put the MCP server a region away from its database and each tool call carries an extra cross-region round trip on top of whatever the client already paid to reach the server. Put the server on the branch and that part is effectively free. For a server whose entire job is querying Postgres, that is the hop worth optimizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  One endpoint per branch
&lt;/h2&gt;

&lt;p&gt;There is a second thing you get for free here. Neon Functions are deployed per branch, and each branch has its own function URL. Because a branch is also a copy of your data, that means every branch can have its own MCP server over its own dataset.&lt;/p&gt;

&lt;p&gt;Spin up a branch for a preview environment and it comes with an MCP endpoint backed by that branch's data. Give an agent a scratch branch to work against and it cannot touch production. Run your CI against a branch and the agent's tools operate on the ephemeral copy, then it all gets thrown away with the branch. You are not standing up and tearing down a separate MCP service per environment; the endpoint rides along with the branch you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;The full example, with all four CRUD tools, the schema, the deploy config, and client test scripts, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-mcp-demo" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-mcp-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;An MCP server that fronts a database is mostly network and queries, and the network part is worth taking seriously because an agent may call these tools dozens of times in a single task. Neon Functions let you collapse the server-to-database distance to a same-region hop by deploying the MCP server onto the branch it queries, and the code to do it is small: a schema, a tool that runs a query, and the streamable HTTP transport. Point any MCP client at the URL and the agent has typed, database-backed tools running right next to the data. Give each branch its own endpoint and you get isolated, per-environment agent tooling without any extra services to run.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Streaming an AI Agent Without a Function Timeout</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:16:30 +0000</pubDate>
      <link>https://dev.to/devopsdaily/streaming-an-ai-agent-without-a-function-timeout-1eoe</link>
      <guid>https://dev.to/devopsdaily/streaming-an-ai-agent-without-a-function-timeout-1eoe</guid>
      <description>&lt;p&gt;An AI agent and a serverless function want different things. The agent wants to think, call a tool, stream some tokens, call another tool, and keep the connection open the whole time, which can be tens of seconds or more. A lot of serverless tiers want the opposite: do your work quickly and return, because the invocation has an execution cap. Put them together and you get the failure everyone who has shipped an agent has seen at least once: the response is still streaming when the platform decides time is up and closes the socket.&lt;/p&gt;

&lt;p&gt;This is the second post in our series on &lt;a href="https://neon.com/docs/compute/functions/overview" rel="noopener noreferrer"&gt;Neon Functions&lt;/a&gt;. The first was about &lt;a href="https://devops-daily.com/posts/neon-functions-compute-on-your-database-branch" rel="noopener noreferrer"&gt;where your compute runs relative to your data&lt;/a&gt;; this one is about how long it is allowed to keep talking. Neon Functions are built to hold long-lived streaming connections, so a slow agent or a long stream is a normal request, not a fight with a timeout. To show it rather than assert it, I deployed two endpoints and measured them.&lt;/p&gt;

&lt;p&gt;(Companion repo, deploy it yourself: &lt;a href="https://github.com/The-DevOps-Daily/neon-streaming-demo" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-streaming-demo&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Two endpoints, one config
&lt;/h2&gt;

&lt;p&gt;The whole backend is a single Hono function with the AI Gateway switched on in &lt;code&gt;neon.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neondatabase/config/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;aiGateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streaming demo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The streaming itself is ordinary Hono. The first endpoint holds a server-sent-events connection open and emits a tick every second, for as many seconds as you ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;streamSSE&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono/streaming&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/long-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;90&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;streamSSE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeSSE&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;elapsed_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeSSE&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ticks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  It streamed for 90 seconds without being asked twice
&lt;/h2&gt;

&lt;p&gt;I called &lt;code&gt;/long-stream?seconds=90&lt;/code&gt; and let it run. It ticked once a second, on the second, for a minute and a half, and closed cleanly on its own terms:&lt;/p&gt;

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

&lt;p&gt;Ninety seconds is not a magic number; I picked it because it is comfortably past the execution cap a lot of serverless functions ship with by default, and the function did not care. No special mode, no config flag, no "streaming response" opt-in. The handler just held the connection.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To be precise about the comparison: this is about defaults and design, not "infinite versus finite." Traditional serverless functions cap a single invocation low by default (Vercel's Hobby tier at 10 seconds, Pro at 60), which is exactly where a slow agent gets cut off. Platforms do offer longer runs when you reach for them: Vercel's Fluid Compute extends to 300 to 1800 seconds, and AWS Lambda allows up to 15 minutes. The point is that long-lived streaming is the default behaviour of a Neon Function, not a setting you discover after your agent times out in production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Now stream an actual agent
&lt;/h2&gt;

&lt;p&gt;A ticking clock proves the connection lasts. The real workload is a model streaming tokens. The second endpoint sends the prompt to the &lt;a href="https://neon.com/docs/ai-gateway/overview" rel="noopener noreferrer"&gt;Neon AI Gateway&lt;/a&gt; with &lt;code&gt;stream: true&lt;/code&gt; and relays each token to the caller as it arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEON_AI_GATEWAY_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ai-gateway/mlflow/v1/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEON_AI_GATEWAY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-5-nano&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ...parse the upstream SSE and re-emit each delta as it lands&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeSSE&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling it with a small prompt, the first token came back at &lt;strong&gt;466 ms&lt;/strong&gt; and the full 62-token reply finished at about &lt;strong&gt;2.0 seconds&lt;/strong&gt;. The reader sees the answer forming almost immediately instead of waiting two seconds for a wall of text:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw801gykmc4v27t3jqot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw801gykmc4v27t3jqot.png" alt="Streaming vs waiting: when you see the agent's reply" width="800" height="203"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two seconds is short because the model and the prompt are small. The reason this matters is that real agents are not short: they make several model calls, run tools between them, and a full run is routinely tens of seconds. On a platform that caps invocations at 10 or 60 seconds, that run is a gamble against the clock. On a function built to hold the stream, it is just a request that takes a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is, and what it is not
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Private preview, one region, new projects only.&lt;/strong&gt; Everything is in AWS &lt;code&gt;us-east-2&lt;/code&gt; and only works on projects created inside the preview. Plan accordingly before building on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more things worth knowing before you reach for this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is request/response, even when the response is long.&lt;/strong&gt; These functions answer a caller and can keep streaming to it for a long time, including over WebSockets and SSE. They are not a background job runner. Work that should outlive the request (queues, retries, scheduled tasks) belongs to something like Inngest or QStash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle functions can be evicted.&lt;/strong&gt; A long &lt;em&gt;active&lt;/em&gt; stream is fine; a function sitting idle may be scaled to zero and cold-start on the next call. That is the usual serverless tradeoff, not a streaming-specific one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;If you are shipping anything agentic (a chat assistant, a tool-using agent, a long generation, an MCP server holding a session), the timeout is the wall you hit first, and the usual workaround is to learn your platform's extended-duration mode and hope you configured it right. A function that holds the stream by default removes that whole category of "why did my response get cut off" debugging.&lt;/p&gt;

&lt;p&gt;The full demo, both endpoints, is here. The streaming logic is about 80 lines:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-streaming-demo" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-streaming-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next in the series: a Postgres-backed MCP server in about twenty lines, and preview environments that include the backend, not just the frontend. The strategy behind all of it is in &lt;a href="https://devops-daily.com/posts/neon-backend-platform-not-just-postgres" rel="noopener noreferrer"&gt;Neon is becoming a backend platform, not just Postgres&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Compute That Lives on Your Database Branch</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:59:23 +0000</pubDate>
      <link>https://dev.to/devopsdaily/compute-that-lives-on-your-database-branch-4j1p</link>
      <guid>https://dev.to/devopsdaily/compute-that-lives-on-your-database-branch-4j1p</guid>
      <description>&lt;p&gt;Ask where your backend code runs relative to your database and the answer is often "somewhere else." Your function is in one provider's &lt;code&gt;us-east-1&lt;/code&gt;, your Postgres is in another region entirely, and every query crosses that gap. Most of the time you don't see it, because one query is fast enough to ignore. Then a request makes eight queries in sequence, each pays the round trip, and suddenly an endpoint that should take milliseconds takes most of a second.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://neon.com/docs/compute/functions/overview" rel="noopener noreferrer"&gt;Neon Functions&lt;/a&gt;, part of Neon's June 2026 platform preview, takes a different position: run the compute in the same region as the database branch, on a URL scoped to that branch. This is the first in a series on what that buys you. It is also the simplest to demonstrate, because the benefit is something you can measure. I deployed a small REST API and timed a trivial query two ways. The numbers are at the bottom, and they are not close.&lt;/p&gt;

&lt;p&gt;(Companion repo, deploy it yourself: &lt;a href="https://github.com/The-DevOps-Daily/neon-functions-demo" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-functions-demo&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole backend is one config file
&lt;/h2&gt;

&lt;p&gt;Neon ships starter templates through its CLI. The REST API is one of them:&lt;/p&gt;

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

&lt;p&gt;What gets deployed is declared in &lt;code&gt;neon.ts&lt;/code&gt;. For this API it is three lines of intent: take &lt;code&gt;src/index.ts&lt;/code&gt; and run it as a function called &lt;code&gt;todos&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neondatabase/config/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todo api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No connection string in there, no region to pick for the compute, no URL to reserve. The &lt;code&gt;DATABASE_URL&lt;/code&gt; is injected at deploy time, and the function lands in the same region as the branch automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The function is a normal web handler
&lt;/h2&gt;

&lt;p&gt;There is nothing Neon-specific in the application code. It is a standard &lt;a href="https://hono.dev" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; app talking to Postgres through a connection pool, the same code you would write for any Node host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;drizzle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm/node-postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neondatabase/env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../neon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db/schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hono&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;neonctl deploy&lt;/code&gt;, that handler answers at a branch-scoped URL, and the create/read path works end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;/todos"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text":"ship it"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"id":1,"text":"ship it","createdAt":"2026-06-25T16:17:10.692Z"}  (201)&lt;/span&gt;
curl &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;/todos"&lt;/span&gt;
&lt;span class="c"&gt;# [{"id":1,"text":"ship it","createdAt":"2026-06-25T16:17:10.692Z"}]  (200)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phrase "branch-scoped URL" is the part worth slowing down on. Open a branch off this one and it gets its own function at its own URL, running your latest code against that branch's data. The preview environment for a pull request stops being "the frontend plus a shared backend" and becomes a real, isolated copy. We will spend a whole post on that later; for now, the point is that the function and the branch are one unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now measure the distance
&lt;/h2&gt;

&lt;p&gt;Here is the part you can put a number on. The function exposes a &lt;code&gt;/db-latency&lt;/code&gt; endpoint that times thirty &lt;code&gt;SELECT 1&lt;/code&gt; round trips from inside the handler and returns the median. Because the function runs in the same region as the branch, this is the local hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;/db-latency"&lt;/span&gt;
&lt;span class="c"&gt;# { "from": "neon function (us-east-2, co-located with Postgres)",&lt;/span&gt;
&lt;span class="c"&gt;#   "runs": 30, "min_ms": 1.13, "median_ms": 1.19, "p95_ms": 1.62 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just over a millisecond. Then I ran the exact same &lt;code&gt;SELECT 1&lt;/code&gt;, against the exact same database, from a machine in Europe (this site's build box, a Raspberry Pi a long way from &lt;code&gt;us-east-2&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# same query, same database, from a machine on another continent&lt;/span&gt;
&lt;span class="c"&gt;# { "from": "europe -&amp;gt; us-east-2", "runs": 30,&lt;/span&gt;
&lt;span class="c"&gt;#   "min_ms": 130.46, "median_ms": 134.54, "p95_ms": 138 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same query, same database. The only thing that changed is where the caller sits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3ipfvfhvfxl1fo32siv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3ipfvfhvfxl1fo32siv.png" alt="Median time for one SELECT 1 round trip" width="799" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;About 113x. And that is for one round trip. A request that reads a session, loads a user, fetches their settings, and runs three more queries pays that distance once per query if it runs them in sequence. At 1.2 ms the six-query endpoint spends roughly 7 ms talking to the database; at 135 ms it spends most of a second, and no amount of application tuning fixes it, because the time is in the network. This is the tax co-located compute removes. It is also where a lot of "serverless Postgres is slow" folklore actually comes from: not the database, but a function in one region reconnecting to a database in another on every cold start.&lt;/p&gt;

&lt;p&gt;To be fair about the comparison: a real deployment is rarely as far away as Europe-to-Virginia. If your Lambda and your database are both in &lt;code&gt;us-east-1&lt;/code&gt; the gap is smaller. But "both in the same region" is exactly the property Neon Functions give you by default instead of by careful configuration, and "smaller" is not "zero."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is, and what it is not
&lt;/h2&gt;

&lt;p&gt;A few things are worth stating plainly before you build around this, because it is a private preview and it has clear edges.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Private preview, one region, new projects only.&lt;/strong&gt; Everything is in AWS &lt;code&gt;us-east-2&lt;/code&gt; and only works on projects created inside the preview. You cannot turn this on for an existing production database today.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beyond that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;These are request/response functions, not a job runner.&lt;/strong&gt; They are built for APIs, agents, webhooks, and real-time connections (they support streaming and long-lived sockets, not just quick replies). Background work, queues, retries, and schedules are a different kind of compute; pair them with something like Inngest or QStash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function memory is fixed&lt;/strong&gt; (2048 MiB at preview), so this is not yet a knob-for-everything compute platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a Neon-shaped commitment.&lt;/strong&gt; One config file declaring your functions is convenient precisely because it is integrated. That is coupling, traded for the locality and the branching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;If your backend already lives in mature infrastructure-as-code with compute and database carefully placed in the same region, Neon Functions are not solving a problem you have. You already paid the cost to make the hop short.&lt;/p&gt;

&lt;p&gt;The teams this helps are the ones who never got around to that: side projects and small teams whose compute and database drifted into different regions because nobody decided otherwise, and anyone who wants a pull request to spin up a genuinely isolated backend without wiring it by hand. For them, "the function runs next to the database, on this branch's data, at this URL" is a real reduction in both latency and moving parts, and it is the default rather than a configuration you have to get right.&lt;/p&gt;

&lt;p&gt;We dig into the bigger picture in &lt;a href="https://devops-daily.com/posts/neon-backend-platform-not-just-postgres" rel="noopener noreferrer"&gt;Neon is becoming a backend platform, not just Postgres&lt;/a&gt;, and the rest of this series walks through the other things a branch-scoped function unlocks: streaming agents, MCP servers, and preview environments that include the backend. The full demo, including the &lt;code&gt;/db-latency&lt;/code&gt; endpoint, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-functions-demo" rel="noopener noreferrer"&gt;https://github.com/The-DevOps-Daily/neon-functions-demo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
