<?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: Ricardo Morato</title>
    <description>The latest articles on DEV Community by Ricardo Morato (@0311b).</description>
    <link>https://dev.to/0311b</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%2F4167297%2Ff844c745-b305-4340-bca9-98b0d752a648.png</url>
      <title>DEV Community: Ricardo Morato</title>
      <link>https://dev.to/0311b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0311b"/>
    <language>en</language>
    <item>
      <title>Why I rewrote aside</title>
      <dc:creator>Ricardo Morato</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/why-i-rewrote-aside-3aj5</link>
      <guid>https://dev.to/0311b/why-i-rewrote-aside-3aj5</guid>
      <description>&lt;p&gt;I've rebuilt &lt;a href="https://aside.pro" rel="noopener noreferrer"&gt;aside&lt;/a&gt; more than once, and the last rewrite is the one that changed the most. Before I say what I changed, the first thing: I tried to sell aside and I couldn't. I'm not saying it with drama, it's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;Between March and May I was posting about aside on LinkedIn. I saw no results and I stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was before
&lt;/h2&gt;

&lt;p&gt;The first version "saw" the screen and decided on the spot where to click or what to highlight. It failed too much. When something fails because of how it's set up, tuning doesn't fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Now it's your app that tells aside what it can do: create an invoice, change a setting, open a screen. aside chooses what to do and with what data, and your own code runs it.&lt;/p&gt;

&lt;p&gt;You tell it "invoice Orbit Labs €2,400 for the month of September" and it leaves the invoice filled in, ready for you to send. What changes is who decides what is possible: your app, not aside guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I removed
&lt;/h2&gt;

&lt;p&gt;Before, you could also talk to it by voice. I realised it caused problems, it was very expensive and it wasn't essential. I removed it to focus on making what it does work well every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price
&lt;/h2&gt;

&lt;p&gt;Before, it started at €249, too much for solo founders and small startups. Now it starts at €49.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and now
&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;Before&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How it decides what to do&lt;/td&gt;
&lt;td&gt;It saw the screen and decided where to click&lt;/td&gt;
&lt;td&gt;The app tells it what it can do, aside picks and the SaaS's own code runs it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice&lt;/td&gt;
&lt;td&gt;You could talk to it&lt;/td&gt;
&lt;td&gt;I removed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;From €249&lt;/td&gt;
&lt;td&gt;From €49&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;p&gt;Two things. The first is onboarding: it takes your users through setup step by step, in a guided and natural way, and fills in each field while they talk. The second is support: it answers with your documentation and, when that isn't enough, it acts.&lt;/p&gt;

&lt;p&gt;aside's own dashboard runs on aside: you tell it in one sentence how you want your agent and it sets it up for you.&lt;/p&gt;

&lt;p&gt;All of this came from talking to customers and seeing where it failed. I don't know if it's the final version, but it is the most polished one.&lt;/p&gt;

&lt;h2&gt;
  
  
  So far, three conversations
&lt;/h2&gt;

&lt;p&gt;I've talked to three leads. That's few, and I'm not going to draw market conclusions from three people. What I'm telling you is what happened.&lt;/p&gt;

&lt;p&gt;One told me the price was expensive. The other two never replied.&lt;/p&gt;

&lt;p&gt;About the one who said it was expensive I know something concrete: for that person, the price is an objection. It's a data point, even if it's only one.&lt;/p&gt;

&lt;p&gt;About the two who disappeared I know nothing. A silence is not a no, and it's not a yes either. It doesn't tell me whether they weren't interested in the product, whether the timing was bad or whether my message didn't manage to say anything to them.&lt;/p&gt;

&lt;p&gt;With three conversations there is no pattern yet.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why your webhooks arrive duplicated and how to stop them costing an invoice</title>
      <dc:creator>Ricardo Morato</dc:creator>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/why-your-webhooks-arrive-duplicated-and-how-to-stop-them-costing-an-invoice-24gp</link>
      <guid>https://dev.to/0311b/why-your-webhooks-arrive-duplicated-and-how-to-stop-them-costing-an-invoice-24gp</guid>
      <description>&lt;p&gt;In an ERP, a duplicated webhook is not a technical bug. It's a wrongly issued invoice.&lt;/p&gt;

&lt;p&gt;I work on the ecommerce integrations of a SaaS ERP: Shopify, WooCommerce, PrestaShop. Each store sends us events about its orders, and the expensive mistake is always the same: the system does the same thing twice, or does today what was already corrected yesterday. In this post I explain the layers I use to avoid it and why each one exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can't assume about a webhook
&lt;/h2&gt;

&lt;p&gt;A webhook does not guarantee that an event arrives only once or in order. Shopify, for example, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/best-practices" rel="noopener noreferrer"&gt;does not guarantee ordering within a topic or across topics for the same resource&lt;/a&gt; and &lt;a href="https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates" rel="noopener noreferrer"&gt;recommends ignoring duplicates&lt;/a&gt; with the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;With one store it's rare. With thousands of integrations, rare stops being rare: any combination of duplicate, concurrency and disorder happens every day. That's why there is no single solution. There are layers, and each one covers a gap the others don't see.&lt;/p&gt;

&lt;p&gt;Before the layers there is a base rule: the endpoint only verifies the signature, stores the event exactly as it arrives and responds 200. Everything else happens in a worker, so the response does not depend on how long processing takes. Storing the raw event also lets you reprocess and audit it when there is a bug.&lt;/p&gt;

&lt;p&gt;And a webhook is a notification, not the truth. For a critical decision, like issuing an invoice, it's better to request the order again from the store's API instead of trusting the payload you received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: discard the identical before processing it
&lt;/h2&gt;

&lt;p&gt;The cheapest one. You compute a hash of the payload and store it in Redis with an expiry. If it already exists, the event is a repeat and you discard it without touching the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"webhook:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// SET NX EX: check and mark in a single atomic operation&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="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'NX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EX'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&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;// already received, discard&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// durable, unique per event id&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="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// if it wasn't stored, the provider's retry must be able to get through&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$e&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;What matters is that checking and marking is a single operation, which is exactly what &lt;a href="https://redis.io/docs/latest/commands/set/" rel="noopener noreferrer"&gt;&lt;code&gt;SET&lt;/code&gt; with &lt;code&gt;NX&lt;/code&gt; and &lt;code&gt;EX&lt;/code&gt;&lt;/a&gt; does. If you do a &lt;code&gt;GET&lt;/code&gt; and then a &lt;code&gt;SET&lt;/code&gt;, two copies of the same event that arrive at the same time both get through. The key includes the store because the same id can repeat across different stores.&lt;/p&gt;

&lt;p&gt;Even so, this layer is an optimisation: Redis can lose or expire the key, so the deduplication that guarantees anything is the durable one, with a unique constraint on the event id. What Redis does do is take work off your plate: in my case it discards about a quarter of the events before they use up resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: a lock per order
&lt;/h2&gt;

&lt;p&gt;The hash doesn't help against two different events for the same order that arrive almost at once. If two workers process them in parallel, each one reads the order's state, modifies it and writes it, and the last one to write wins.&lt;/p&gt;

&lt;p&gt;The solution is to serialise the work per order with a distributed lock: before processing, the worker takes that order's lock and, if another worker holds it, it retries a bounded number of times, with some randomness in the wait, or puts the event back on the queue. But the lock coordinates, it doesn't prove correctness. What really prevents losing a write is saving with a conditional write by version, which fails if someone got there first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="nv"&gt;$lockKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lock:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:order:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// unique per acquisition&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="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'NX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PX'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30000&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RetryLater&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// another worker is on this order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the final save is a conditional write by version&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Lua script: if get(key) == token then del(key); never GET and then DEL&lt;/span&gt;
    &lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RELEASE_IF_OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter: the lock expires (it's a lease, with a duration longer than the measured time the work takes), the value is a unique token per acquisition and it's released by comparing that token atomically. Even so, if the worker takes longer than the lease, another one gets in while the first is still writing. That's why the lock doesn't replace the conditional write or business idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: ignore what arrives late
&lt;/h2&gt;

&lt;p&gt;Even if they don't overwrite each other, events can arrive out of order. An ordering guard compares the event's date with the date of the last state you have already applied, discards the one that is older and records that it discarded it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="c1"&gt;// The watermark lives in the order itself&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;emittedAt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lastEmittedAt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webhook.stale_discarded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'orderId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderId&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;// older than what has already been applied&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// when saving, the same check goes inside the conditional write&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have to decide what you compare. The event's date at the provider, not the time it reached you, because delivery can be delayed. If the provider also gives a version or a sortable id, store it: two updates in the same second tie on date. And the check must travel in the conditional write, not only in a previous &lt;code&gt;if&lt;/code&gt;, because between the &lt;code&gt;if&lt;/code&gt; and the save another worker may have got ahead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: what a hash can't see
&lt;/h2&gt;

&lt;p&gt;The same refund can arrive by two different routes, with different payloads. To the hash they are two unrelated events, so both get through.&lt;/p&gt;

&lt;p&gt;Here I don't deduplicate messages, I apply business idempotency: the key comes from the identity of the refund (the store and the refund id), not from the content or the delivery. The question changes from "have I seen this message?" to "have I already applied this refund?". That key has to live in the database with a unique constraint and store the result, so a retry returns the same thing. In Redis, which expires, it's not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't overwrite what the merchant edited by hand
&lt;/h2&gt;

&lt;p&gt;A merchant can edit a document by hand after we have generated it. If another sync event arrives, you don't want to overwrite that edit.&lt;/p&gt;

&lt;p&gt;For that I compare two hashes: one of the fiscal fields and one of the non-fiscal fields. That way I see what has really changed between what I have and what arrives, and I don't flatten a manual edit. So that the hash doesn't invent differences, you have to normalise the amounts to the same precision and sort the keys before computing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exchange rate, fixed to the original document
&lt;/h2&gt;

&lt;p&gt;A detail that causes false differences: if an order is in another currency and you recalculate with the exchange rate of each event, the volatility between one event and the next makes the amounts not add up even though nothing has changed.&lt;/p&gt;

&lt;p&gt;I fix the exchange rate of the original document across the whole chain of events, so the amount only changes when the order changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of the layers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;th&gt;Where the guarantee is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Hash in Redis&lt;/td&gt;
&lt;td&gt;Processing an identical event twice&lt;/td&gt;
&lt;td&gt;In durable deduplication by event id: Redis only saves work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Lock per order&lt;/td&gt;
&lt;td&gt;Two workers processing the same order at once&lt;/td&gt;
&lt;td&gt;In the conditional write by version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Ordering guard&lt;/td&gt;
&lt;td&gt;An old event overwriting a new one&lt;/td&gt;
&lt;td&gt;In the check inside the conditional write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Business idempotency&lt;/td&gt;
&lt;td&gt;Applying the same refund twice&lt;/td&gt;
&lt;td&gt;In a durable key with a unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;None of these pieces is sophisticated on its own. What's hard is having all of them, and having every new incident come in with a test that stops it from happening again; how I automate that cycle is in &lt;a href="https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1ah7-temp-slug-9600754"&gt;From a Slack thread to a pull request&lt;/a&gt;. With the four layers and those two precautions, a repeated or out-of-order event should not change the order twice.&lt;/p&gt;

&lt;p&gt;And since webhooks also get lost, the system has to converge even if none arrives: that takes a periodic reconciliation against the store, which &lt;a href="https://shopify.dev/docs/apps/build/webhooks/best-practices" rel="noopener noreferrer"&gt;Shopify also recommends&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>architecture</category>
      <category>backend</category>
      <category>redis</category>
    </item>
    <item>
      <title>From timeout to 10 seconds: finding the real bottleneck</title>
      <dc:creator>Ricardo Morato</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/from-timeout-to-10-seconds-finding-the-real-bottleneck-3oh1</link>
      <guid>https://dev.to/0311b/from-timeout-to-10-seconds-finding-the-real-bottleneck-3oh1</guid>
      <description>&lt;p&gt;There was a screen that, on large accounts, would not open.&lt;/p&gt;

&lt;p&gt;It was the listing of document items. On accounts with a lot of volume it hit the nginx timeout, 60 seconds, and for the customer it was simply unreachable. That module was already built. I rebuilt it on my own, frontend and backend.&lt;/p&gt;

&lt;p&gt;The backend is PHP with Symfony and CQRS, and the frontend is React. I simplify the code in this post so the idea is clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure before touching
&lt;/h2&gt;

&lt;p&gt;The temptation is to optimise the first thing that smells bad. Before that, I looked at where the time and the memory were going, and not only the total time: a high total time doesn't tell you whether the problem is the database, the ORM or the frontend. The bottleneck was the ORM's memory consumption.&lt;/p&gt;

&lt;p&gt;When an ORM loads many records, it turns each one into objects, and those objects take up memory for as long as they stay alive. With tens of thousands of products, that memory shoots up and the listing never finishes in time.&lt;/p&gt;

&lt;h2&gt;
  
  
  First change: process in batches
&lt;/h2&gt;

&lt;p&gt;Instead of loading all the items at once, I process them in batches and free memory between one and the next. This is the pattern in the &lt;a href="https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/batch-processing.html" rel="noopener noreferrer"&gt;Doctrine batch processing documentation&lt;/a&gt;, which calls &lt;code&gt;clear()&lt;/code&gt; between batches to free memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$batchSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findByIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// releases the objects from the previous batch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step still hydrates full entities, but memory stops growing with the size of the account and depends mostly on the size of the batch. That size is a trade-off: bigger performs better, smaller uses less memory, and you choose it by measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second change: a view instead of the aggregate
&lt;/h2&gt;

&lt;p&gt;Even when processing in batches, loading the full aggregate of each item is expensive. The aggregate exists to protect invariants when writing, and to render a list you don't need it. So I applied a view: the listing no longer goes through the aggregate and reads a read projection, a flat object with only the fields the screen renders.&lt;/p&gt;

&lt;p&gt;In CQRS that is a query with its handler, and the handler loads nothing: it asks a read gateway for the data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsQuery&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsQueryHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;DocumentItemsReadGateway&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="cd"&gt;/** @return iterable&amp;lt;DocumentItemView&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ListDocumentItemsQuery&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;documentId&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 handler returns an iterable, not an array. That is the key to everything that comes next: with a generator, only one row is in memory at a time, the one being processed.&lt;/p&gt;

&lt;p&gt;The read gateway is the one that talks to the database. It projects only the fields that are needed, sorts in a stable way and hands out rows with &lt;code&gt;yield&lt;/code&gt; as the cursor brings them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MongoDocumentItemsReadGateway&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DocumentItemsReadGateway&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return \Generator&amp;lt;DocumentItemView&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;\Generator&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenantId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'documentId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'projection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'quantity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'sort'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;// stable order, with a unique tie-breaker&lt;/span&gt;
                &lt;span class="s1"&gt;'batchSize'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batchSize&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cursor&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;DocumentItemView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&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 heavy fields, the ones only needed on small accounts or when opening the detail, are loaded conditionally depending on the size of the account. Separating reads and writes this way doesn't require a second database or event sourcing: the separation is logical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third change: streaming from the controller
&lt;/h2&gt;

&lt;p&gt;With an iterable in hand, the controller can start responding without waiting to have all the rows. In Symfony this is done with a &lt;a href="https://symfony.com/doc/current/components/http_foundation.html#streaming-a-response" rel="noopener noreferrer"&gt;&lt;code&gt;StreamedResponse&lt;/code&gt;&lt;/a&gt; that writes one line of JSON per item (NDJSON) and flushes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Route('/documents/{documentId}/items', methods: ['GET'])]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;QueryBus&lt;/span&gt; &lt;span class="nv"&gt;$queryBus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TenantContext&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StreamedResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queryBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ask&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;ListDocumentItemsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$response&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;StreamedResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// one row per line&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// sent in batches, not row by row&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;connection_aborted&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;// the client has gone, we stop reading&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="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/x-ndjson'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Accel-Buffering'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// nginx must not buffer the response&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&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;Two details matter. The first, &lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering" rel="noopener noreferrer"&gt;&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;&lt;/a&gt;: without it, nginx buffers the response and the client receives nothing until it finishes, and the streaming is pointless. The second, that &lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout" rel="noopener noreferrer"&gt;nginx's read timeout is measured between two reads, not over the whole response&lt;/a&gt;: as long as rows keep arriving, it doesn't trigger. Symfony also ships &lt;a href="https://symfony.com/doc/current/components/http_foundation.html#streaming-a-json-response" rel="noopener noreferrer"&gt;&lt;code&gt;StreamedJsonResponse&lt;/code&gt;&lt;/a&gt;, which accepts generators; here I use NDJSON because the frontend reads the stream line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourth change: React renders with the first results
&lt;/h2&gt;

&lt;p&gt;The other side is making the frontend not wait. To read the stream, an async generator that splits the lines as they arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;streamItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&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;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// In the real frontend this sits behind a repository, with the whole DDD layer; here it goes straight to fetch to keep it simple&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;`/api/documents/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&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="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;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&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;pipeThrough&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;TextDecoderStream&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;getReader&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;buffer&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;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&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="c1"&gt;// the last line may arrive half-finished&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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&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;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DocumentItem&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;And to hook it up to &lt;code&gt;useQuery&lt;/code&gt;, TanStack Query's &lt;a href="https://tanstack.com/query/latest/docs/framework/react/reference/functions/experimental_streamedQuery" rel="noopener noreferrer"&gt;&lt;code&gt;streamedQuery&lt;/code&gt;&lt;/a&gt;: it accumulates the chunks that arrive into an array. The query is &lt;code&gt;pending&lt;/code&gt; until the first one arrives, moves to &lt;code&gt;success&lt;/code&gt; at that moment, and stays &lt;code&gt;fetching&lt;/code&gt; until the stream closes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;queryOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experimental_streamedQuery&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;streamedQuery&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;@tanstack/react-query&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;documentItemsQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&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="nf"&gt;queryOptions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&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;document-items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;streamedQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;streamFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;streamItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DocumentItemsList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;documentId&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;documentItemsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&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;isPending&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ListSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading more…&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;streamedQuery&lt;/code&gt; is still marked as experimental in TanStack Query, so its API may change.&lt;/p&gt;

&lt;p&gt;For the person using the screen, the difference is huge: they see the first item almost immediately, instead of having to wait 40 seconds to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to watch out for with streaming
&lt;/h2&gt;

&lt;p&gt;While rows are arriving the total isn't known yet, so the screen shouldn't show an "of N" or an invented percentage. The order has to be stable, with a unique tie-breaker, or a row can appear twice or be skipped. And it's better to send the rows in batches, not one by one, because with tens of thousands of rows rendering each one separately eats the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of the changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;What it removes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Batches&lt;/td&gt;
&lt;td&gt;Memory growing with the size of the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A view instead of the aggregate&lt;/td&gt;
&lt;td&gt;Hydrating the full aggregate of every item&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming from the controller&lt;/td&gt;
&lt;td&gt;Waiting for all the rows before responding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React with &lt;code&gt;streamedQuery&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Waiting for everything to arrive before rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;From timeout to about 10 seconds, with the screen filling up from the first results. With that you can get into accounts with more than 100,000 products.&lt;/p&gt;

&lt;p&gt;Ten seconds is not fast, but before it didn't open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing, at another scale
&lt;/h2&gt;

&lt;p&gt;In the webhook pipeline, which I cover in &lt;a href="https://0311b.com/en/blog/duplicated-webhooks/" rel="noopener noreferrer"&gt;Why your webhooks arrive duplicated&lt;/a&gt;, I follow the same rule. I use Datadog's APM and traces to see which parts of the flow are really slow, and I aim the optimisation there, not where it seems. The traces are sampled and are useful for performance; it's also worth measuring how long each message waits in the queue, because sometimes the slow part is the wait and not the code.&lt;/p&gt;

&lt;p&gt;Before optimising, measure.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>react</category>
      <category>performance</category>
    </item>
    <item>
      <title>From a Slack thread to a pull request: an agent that reproduces the incident</title>
      <dc:creator>Ricardo Morato</dc:creator>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1nj7</link>
      <guid>https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1nj7</guid>
      <description>&lt;p&gt;A customer incident usually starts with a support message in Slack: this store had this happen with an order. And from there the path is almost always the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual path
&lt;/h2&gt;

&lt;p&gt;Find the pipeline logs, reconstruct which webhooks arrived and in what order, understand what happened, write a test that reproduces it and, finally, fix it. Almost all of it is mechanical, and the only thing that changes from one incident to the next is the case. That's why I built an agent that walks that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent does
&lt;/h2&gt;

&lt;p&gt;Starting from the Slack thread:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It retrieves the pipeline logs.&lt;/li&gt;
&lt;li&gt;It reconstructs the real sequence of webhooks we received from the store.&lt;/li&gt;
&lt;li&gt;It generates a test that reproduces the case.&lt;/li&gt;
&lt;li&gt;It proposes the fix in a pull request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When reconstructing the sequence you have to look at two clocks: the order in which each webhook arrived and the date on which the provider emitted it. Many incidents are exactly the difference between the two; I cover the layers I use to prevent them in &lt;a href="https://0311b.com/en/blog/duplicated-webhooks/" rel="noopener noreferrer"&gt;Why your webhooks arrive duplicated&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  First the test, then the fix
&lt;/h2&gt;

&lt;p&gt;The order matters. In my area, every incident comes in with a test that stops it from happening again, so the agent doesn't start by fixing: it starts by reproducing.&lt;/p&gt;

&lt;p&gt;A test that fails for the right reason is the proof that you have reproduced the problem, and reproducing it is the first step to understanding it. If the agent can't reproduce the case, there is nothing to fix yet. Sometimes the cause is that a piece of data is missing: a webhook that never arrived or an external result that was left in doubt. That is also a useful result, and it's better to know it before it proposes a blind patch.&lt;/p&gt;

&lt;p&gt;For a webhook incident, the test has to be deterministic: the events in the order in which they happened, a fixed clock and fakes that respect the same constraints as production, like uniqueness and versions, because a fake without them makes a concurrency test lie. The payloads come from real cases, but they have to be scrubbed before they are left in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The panel that already existed
&lt;/h2&gt;

&lt;p&gt;Before this I had built another piece in the same direction: an operations panel for the webhook pipeline, so that Customer Support can diagnose a case without escalating it to engineering. You search by store, provider and event type, you see the processing state live, and each order has its timeline with all its events chained by correlation ID.&lt;/p&gt;

&lt;p&gt;Logs explain what a process did, but they can be sampled or have expired. The current state and its history of transitions live in a durable entity, and that's where it's best to read what happened. Logs and traces point to that state, they don't replace it.&lt;/p&gt;

&lt;p&gt;It's the same idea from two sides: making the path between "something has failed" and "I know exactly what happened" short.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>automation</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
