<?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: Andres Victoria </title>
    <description>The latest articles on DEV Community by Andres Victoria  (@member_8be1f66f).</description>
    <link>https://dev.to/member_8be1f66f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3980218%2Fbdae406e-d4a2-43fd-b69d-8d297c6a3e72.png</url>
      <title>DEV Community: Andres Victoria </title>
      <link>https://dev.to/member_8be1f66f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/member_8be1f66f"/>
    <language>en</language>
    <item>
      <title>Why I replaced DynamoDB with Aurora PostgreSQL Serverless v2 for an ad-ops monitoring workload</title>
      <dc:creator>Andres Victoria </dc:creator>
      <pubDate>Thu, 11 Jun 2026 22:18:59 +0000</pubDate>
      <link>https://dev.to/member_8be1f66f/why-i-replaced-dynamodb-with-aurora-postgresql-serverless-v2-for-an-ad-ops-monitoring-workload-b47</link>
      <guid>https://dev.to/member_8be1f66f/why-i-replaced-dynamodb-with-aurora-postgresql-serverless-v2-for-an-ad-ops-monitoring-workload-b47</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjk6ej0bgnmr3mcc1e21.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjk6ej0bgnmr3mcc1e21.png" alt=" " width="800" height="419"&gt;&lt;/a&gt;On February 14, 2026, Meta started enforcing health-data restrictions that broke pixel-based lower-funnel conversion tracking for DTC telehealth advertisers. No purchase events. No lead events. Attribution for GLP-1, TRT, hair-loss, mental-health, and compounding-pharmacy campaigns went dark overnight.&lt;/p&gt;

&lt;p&gt;I run paid acquisition for Bliss Health. At 11 PM the night it shipped, I watched a single ad set burn $4,200 — frequency at 4.7, CTR collapsing, conversion data delayed by Meta's own pipeline. The alert that should have fired four hours earlier didn't exist.&lt;/p&gt;

&lt;p&gt;When attribution dies, you need leading indicators: frequency saturation, CTR trend against a rolling baseline, CAC vs. median. These metrics are computable from raw delivery data the Meta Marketing API still returns in real time. The tool I wanted didn't exist, so I built it for the H0 hackathon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad Engine:&lt;/strong&gt; polls Meta + Google every 15 minutes, runs 5 alert rules, sends each fired alert to Claude Sonnet 4.6, which returns a schema-enforced JSON diagnosis with three ranked actions tagged by urgency now / today / this_week). Operator gets a 30-second decision instead of a 6-hour investigation. Live at &lt;a href="https://runadengine.com" rel="noopener noreferrer"&gt;runadengine.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The architecture decision I want to talk about is the database. H0 lets you pick between Amazon Aurora PostgreSQL, Aurora DSQL, and DynamoDB. My original plan was DynamoDB. I swapped to Aurora Serverless v2 mid-build. Here's the actual decision matrix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload characteristic&lt;/th&gt;
&lt;th&gt;DynamoDB&lt;/th&gt;
&lt;th&gt;Aurora Serverless v2&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Multi-entity joins&lt;/td&gt;
&lt;td&gt;Single-item access patterns&lt;/td&gt;
&lt;td&gt;Native SQL joins&lt;/td&gt;
&lt;td&gt;Aurora&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-series windowing&lt;/td&gt;
&lt;td&gt;Manual GSI design + Lambda step&lt;/td&gt;
&lt;td&gt;OVER ROWS BETWEEN N PRECEDING&lt;/td&gt;
&lt;td&gt;Aurora&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read-heavy analytical&lt;/td&gt;
&lt;td&gt;RCU per item read&lt;/td&gt;
&lt;td&gt;Read replica + buffer cache&lt;/td&gt;
&lt;td&gt;Aurora&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle cost (15-min poll cadence)&lt;/td&gt;
&lt;td&gt;$0 + RCU per request&lt;/td&gt;
&lt;td&gt;0.5 ACU baseline (~$43/mo)&lt;/td&gt;
&lt;td&gt;Even&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Burst writes when alerts fire&lt;/td&gt;
&lt;td&gt;On-demand RCU scaling&lt;/td&gt;
&lt;td&gt;ACU autoscale to 1.0&lt;/td&gt;
&lt;td&gt;Aurora&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query flexibility (evolving shape)&lt;/td&gt;
&lt;td&gt;Pre-planned access pattern&lt;/td&gt;
&lt;td&gt;Ad-hoc SQL&lt;/td&gt;
&lt;td&gt;Aurora&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DynamoDB wins decisively for high-velocity single-item writes where you can draw the complete access-pattern diagram before writing the first line of application code: session stores, leaderboards, IoT telemetry ingestion, shopping-cart state. If that's your workload, DynamoDB is the correct answer and Aurora is overkill.&lt;/p&gt;

&lt;p&gt;My workload is the opposite. Every dashboard render is a multi-table join (accounts × campaigns × ad_sets × ads × insights_hourly × alerts) with time-series windowing for sparkline charts and 7-day baselines for the alert rules. The access pattern evolves every time I add a new rule or a new chart.&lt;/p&gt;

&lt;p&gt;That's an Aurora workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema decision
&lt;/h2&gt;

&lt;p&gt;The hardest part of this swap was the schema, not the swap itself. Here's the insights_hourly table that does most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;insightsHourly&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;insights_hourly&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;level&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;level&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="c1"&gt;// 'campaign' | 'ad_set' | 'ad'&lt;/span&gt;
    &lt;span class="na"&gt;entityMetaId&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;entity_meta_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;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;accountMetaId&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;account_meta_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;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;windowStart&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;window_start&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;spend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;impressions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;impressions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clicks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;conversions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;conversions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ctr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ctr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frequency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;cpa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cpa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;roas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;roas&lt;/span&gt;&lt;span class="dl"&gt;'&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;span class="nx"&gt;t&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;uniqueIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insights_entity_window_idx&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entityMetaId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;windowStart&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;The composite unique index on (level, entity_meta_id, window_start) is doing more work than it looks. It lets the sync function — which is the same cron job that runs every 15 minutes, every reseed during local dev, and every backfill from the Meta API — write with INSERT ... ON CONFLICT DO NOTHING. The pipeline becomes idempotent without a single line of dedup code.&lt;/p&gt;

&lt;p&gt;With DynamoDB, I'd implement this idempotency in application logic (conditional writes against a hashed primary key), and every consumer of the table would have to know about the dedup logic. With Aurora + a unique index, every consumer just queries; the database handles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that justified the swap
&lt;/h2&gt;

&lt;p&gt;The frequency_high alert rule needs to detect creative fatigue — frequency above 4.0 for a sustained period — without falsing on the random spike that any normal ad set hits once. The rule needs a 7-day baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;entity_meta_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_meta_id&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;
    &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;336&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;freq_7d_baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_meta_id&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;
    &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;336&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ctr_7d_baseline&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;insights_hourly&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ad_set'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;account_meta_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_meta_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;336 = 14 days × 24 hours. The window function looks back at the 336 prior rows (per entity) and gives me the rolling average against which the current frequency is judged. The frequency_high rule fires when the current value exceeds 4.0 and exceeds the baseline by some margin.&lt;/p&gt;

&lt;p&gt;This is exactly the query I would have implemented as a Lambda-mediated three-step process against DynamoDB. In Aurora it's one statement, the planner handles the indexing, and adding a sixth or seventh alert rule is a copy-paste exercise instead of a re-architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serverless v2 pricing math
&lt;/h2&gt;

&lt;p&gt;Min capacity 0.5 ACU, max 1.0 ACU for this workload. At 0.5 ACU in us-east-2 the cluster runs about $43/month at full idle. The ACU autoscaler bumps capacity for ~30 seconds when the 15-min cron fires and the alert engine kicks off concurrent Claude calls, then drops back to floor.&lt;/p&gt;

&lt;p&gt;For a pre-revenue product that polls every 15 minutes — most of those polls being no-op writes against an already-current dataset — provisioned Aurora would have wasted credits constantly. Serverless v2's ACU scaling is exactly the right shape for cron-driven bursty-but-predictable workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vercel Fluid Compute connection pooling story
&lt;/h2&gt;

&lt;p&gt;This is the part that took the longest to figure out. Aurora connections are not free. The Postgres protocol does a real TCP + TLS + auth handshake; opening a new connection every Vercel function invocation would saturate the cluster's connection limit in minutes.&lt;/p&gt;

&lt;p&gt;The solution is postgres-js with two specific flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;postgres&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;postgres&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;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// Vercel Fluid Compute instances reuse — no per-statement state&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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// one connection per warm instance, not per request&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;prepare: false is critical because Vercel's Fluid Compute reuses function instances across concurrent requests, but stateful prepared statements would leak between them. max: 1 keeps one warm connection per function instance, which Fluid Compute then reuses for every request that lands on that instance.&lt;/p&gt;

&lt;p&gt;The net result: I get connection pooling for free because Fluid Compute does instance reuse, and I don't need RDS Proxy or an external pgBouncer. For a hackathon-scale deployment, this saves real complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Edge Functions
&lt;/h2&gt;

&lt;p&gt;For completeness: Vercel offers Edge Functions that run on a V8 isolate at the edge. They're faster to start than Node functions. I considered them.&lt;/p&gt;

&lt;p&gt;I rejected them because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Anthropic AI SDK needs Node.js APIs (streams, crypto)&lt;/li&gt;
&lt;li&gt;postgres-js needs raw TCP socket access, which V8 isolates don't expose&lt;/li&gt;
&lt;li&gt;The dashboard's 14-day windowed queries take ~200ms — the Edge-vs-Node latency difference is rounding error against query time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fluid Compute (Node.js, instance reuse, no cold-start tax on warm instances) is the correct primitive for this workload. Edge is correct when you're returning cached HTML at the edge or doing lightweight token validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd reach for DynamoDB for (the honest disclaimer)
&lt;/h2&gt;

&lt;p&gt;I want to be specific about where DynamoDB is the right answer, because nothing in this article is "Aurora always wins":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High-velocity single-item writes&lt;/strong&gt; with a known primary key shape (think IoT telemetry, click-stream ingestion, session tokens, leaderboards, rate-limit counters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable, pre-planned access patterns&lt;/strong&gt; where you can draw the complete GSI layout before writing application code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-region active-active&lt;/strong&gt; where you need writes accepted in any region with eventual consistency, not coordinated joins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workloads where you genuinely don't need joins or window functions&lt;/strong&gt; — and where adding them would require a redesign anyway&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those describe your workload, DynamoDB will beat Aurora on both cost and operational simplicity. Don't read this article as a recommendation to default to relational.&lt;/p&gt;

&lt;p&gt;For an ad-ops monitoring product that needs multi-entity joins, time-series windowing, evolving query shapes, and idempotent bulk-write semantics from a cron job — Aurora Serverless v2 + Drizzle + postgres-js on Vercel Fluid Compute is the most productive stack I've built in years.&lt;/p&gt;

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

&lt;p&gt;The reusable engineering lesson from this project isn't actually the database choice — it's how Claude's structured output via the AI SDK's Output.object({ schema }) lets the AI return data the rest of your system already knows how to use. I'll write that up next.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Try it live:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dashboard demo: &lt;a href="https://runadengine.com/dashboard" rel="noopener noreferrer"&gt;runadengine.com/dashboard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;2-min walkthrough: &lt;a href="https://youtu.be/poFYDHB9WyU" rel="noopener noreferrer"&gt;youtu.be/poFYDHB9WyU&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pilot signup: &lt;a href="https://runadengine.com/signup" rel="noopener noreferrer"&gt;runadengine.com/signup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I created this content for the purposes of entering the H0 Hackathon. #H0Hackathon&lt;/p&gt;

</description>
      <category>aws</category>
      <category>postgressql</category>
      <category>serverless</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
