<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Mary Olowu</title>
    <description>The latest articles on Forem by Mary Olowu (@itsmarydan).</description>
    <link>https://forem.com/itsmarydan</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%2F716762%2F54914207-db14-4bf3-b944-8704ebab0094.jpeg</url>
      <title>Forem: Mary Olowu</title>
      <link>https://forem.com/itsmarydan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/itsmarydan"/>
    <language>en</language>
    <item>
      <title>Stop Writing Custom Scrapers: Index Any Static Content into Meilisearch with One Config File</title>
      <dc:creator>Mary Olowu</dc:creator>
      <pubDate>Wed, 25 Mar 2026 05:16:16 +0000</pubDate>
      <link>https://forem.com/itsmarydan/stop-writing-custom-scrapers-index-any-static-content-into-meilisearch-with-one-config-file-2g65</link>
      <guid>https://forem.com/itsmarydan/stop-writing-custom-scrapers-index-any-static-content-into-meilisearch-with-one-config-file-2g65</guid>
      <description>&lt;p&gt;If you've ever tried to make your docs, blog posts, or changelogs searchable with Meilisearch, you know the drill: write a custom scraper, parse the content, transform it into the right shape, push it to an index, and hope you don't break search during re-indexing.&lt;/p&gt;

&lt;p&gt;I got tired of writing that glue code for every project, so I built &lt;strong&gt;content-mill&lt;/strong&gt; — a CLI and library that indexes static content into Meilisearch from a single YAML config.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Meilisearch is fantastic for search, but getting your content &lt;em&gt;into&lt;/em&gt; it is surprisingly manual. Every docs site, every changelog, every collection of markdown files needs its own extraction pipeline. And if you want zero-downtime re-indexing? That's more code on top.&lt;/p&gt;

&lt;p&gt;Most existing solutions are either tightly coupled to a specific framework (like DocSearch for Algolia) or require you to write a full crawler. If you just have some markdown files and a Meilisearch instance, there's nothing lightweight that bridges the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What content-mill Does
&lt;/h2&gt;

&lt;p&gt;You describe your content sources and the document shape you want in a YAML config:&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="na"&gt;meili&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:7700&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MEILI_MASTER_KEY}&lt;/span&gt;

&lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;docs&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mkdocs&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./mkdocs.yml&lt;/span&gt;
    &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docs&lt;/span&gt;
    &lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;primaryKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;heading&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;section&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nav_section&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs"&lt;/span&gt;
      &lt;span class="na"&gt;searchableAttributes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;filterableAttributes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;section&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @centrali-io/content-mill index &lt;span class="nt"&gt;--config&lt;/span&gt; content-mill.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. content-mill reads your sources, extracts content, applies your field templates, and pushes everything to Meilisearch with atomic index swapping (so search never goes down during re-indexing).&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Source Types, One Interface
&lt;/h2&gt;

&lt;p&gt;content-mill ships with adapters for the content formats you're most likely already using:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mkdocs&lt;/code&gt;&lt;/strong&gt; — Reads your &lt;code&gt;mkdocs.yml&lt;/code&gt;, follows the nav tree, and parses each markdown page. You get &lt;code&gt;nav_section&lt;/code&gt; context so you know which part of the docs each page belongs to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;markdown-dir&lt;/code&gt;&lt;/strong&gt; — Recursively reads &lt;code&gt;.md&lt;/code&gt; files from a directory. Supports YAML frontmatter, so you can pull version numbers, dates, or any metadata into your search index. Great for changelogs and blog posts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;json&lt;/code&gt;&lt;/strong&gt; — Reads a JSON array (or directory of JSON files). Every key in each object becomes a template variable. Perfect for structured data you already have lying around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;html&lt;/code&gt;&lt;/strong&gt; — Reads &lt;code&gt;.html&lt;/code&gt; files, strips scripts/styles/nav/footer, and gives you clean text. Useful for indexing a built static site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Templating: You Control the Document Shape
&lt;/h2&gt;

&lt;p&gt;The key design decision is that &lt;strong&gt;you&lt;/strong&gt; define what your Meilisearch documents look like. Source adapters extract raw variables (&lt;code&gt;slug&lt;/code&gt;, &lt;code&gt;heading&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;frontmatter.*&lt;/code&gt;, etc.), and you map them to fields using &lt;code&gt;{{ template }}&lt;/code&gt; syntax:&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="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}-{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chunk_index&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chunk_heading&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chunk_body&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;excerpt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;truncate(200)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}#{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chunk_heading&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slugify&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filters like &lt;code&gt;truncate&lt;/code&gt;, &lt;code&gt;slugify&lt;/code&gt;, &lt;code&gt;lower&lt;/code&gt;, &lt;code&gt;upper&lt;/code&gt;, and &lt;code&gt;strip_md&lt;/code&gt; can be chained with pipes. This means you're not locked into someone else's schema — your search index looks exactly the way your frontend expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunking for Granular Results
&lt;/h2&gt;

&lt;p&gt;Whole-page results are often too broad for docs search. content-mill can split pages by heading level:&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="na"&gt;chunking&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;heading&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turns one long page into multiple documents — one per &lt;code&gt;##&lt;/code&gt; section — each with its own &lt;code&gt;chunk_heading&lt;/code&gt;, &lt;code&gt;chunk_body&lt;/code&gt;, and &lt;code&gt;chunk_index&lt;/code&gt;. Your search results can now link directly to the relevant section instead of dumping users at the top of a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-Downtime Re-indexing
&lt;/h2&gt;

&lt;p&gt;Every indexing run uses Meilisearch's index swap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Documents go into a temp index (&lt;code&gt;docs_tmp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Atomic swap with the live index (&lt;code&gt;docs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Old index gets cleaned up&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If something fails mid-way, your live index is untouched. No maintenance windows needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD in Two Lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# GitHub Actions&lt;/span&gt;
&lt;span class="pi"&gt;-&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;Index docs&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;MEILI_MASTER_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MEILI_MASTER_KEY }}&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 @centrali-io/content-mill index --config content-mill.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hook this into your release pipeline and your search index stays in sync with every deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use as a Library
&lt;/h2&gt;

&lt;p&gt;Don't need the CLI? Import it 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;loadConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;indexAll&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;@centrali-io/content-mill&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadConfig&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-mill.yml&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;indexAll&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dryRun&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or build the config object in code if you prefer programmatic control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @centrali-io/content-mill
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;content-mill.yml&lt;/code&gt; with your Meilisearch connection and source definitions&lt;/li&gt;
&lt;li&gt;Run with &lt;code&gt;--dry-run&lt;/code&gt; first to preview the extracted documents&lt;/li&gt;
&lt;li&gt;Run for real and check your Meilisearch dashboard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full config reference and source type examples are in the &lt;a href="https://github.com/blueinit/content-mill" rel="noopener noreferrer"&gt;README on GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;content-mill is MIT licensed and open source. If you're using Meilisearch and have static content to index, I'd love to hear how it works for your use case. Issues and PRs welcome on &lt;a href="https://github.com/blueinit/content-mill" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
&lt;em&gt;Tags: #meilisearch #search #typescript #opensource&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

</description>
      <category>meilisearch</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Exponential vs Linear: How to Tell If Your Event-Driven Trigger Is Looping</title>
      <dc:creator>Mary Olowu</dc:creator>
      <pubDate>Sun, 15 Mar 2026 23:46:07 +0000</pubDate>
      <link>https://forem.com/itsmarydan/exponential-vs-linear-how-to-tell-if-your-event-driven-trigger-is-looping-1gc</link>
      <guid>https://forem.com/itsmarydan/exponential-vs-linear-how-to-tell-if-your-event-driven-trigger-is-looping-1gc</guid>
      <description>&lt;h1&gt;
  
  
  Exponential vs Linear: How to Tell If Your Event-Driven Trigger Is Looping
&lt;/h1&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.amazonaws.com%2Fuploads%2Farticles%2Fne8kgwa53mqcx103xz5x.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%2Fne8kgwa53mqcx103xz5x.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Idea
&lt;/h2&gt;

&lt;p&gt;When you're building rate limits for event-driven triggers, you face a fundamental problem: how do you set a threshold that catches loops without blocking legitimate high-volume workloads?&lt;/p&gt;

&lt;p&gt;The answer is that loops and legitimate traffic have fundamentally different growth characteristics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legitimate triggers scale linearly with user actions.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 user creates 1 order → 1 trigger execution&lt;/li&gt;
&lt;li&gt;50 users create 50 orders per minute → 50 trigger executions per minute&lt;/li&gt;
&lt;li&gt;The ratio is always 1:1. Trigger executions track user actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Recursive loops scale exponentially from a single user action.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 user creates 1 record → trigger fires → function creates another record → trigger fires again&lt;/li&gt;
&lt;li&gt;After 10 seconds: 100+ executions&lt;/li&gt;
&lt;li&gt;After 60 seconds: 700+ executions&lt;/li&gt;
&lt;li&gt;All from 1 user action. The trigger is its own input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a subtle distinction. It's the difference between a line and an exponential curve. And it means your rate limit doesn't need to be clever — it just needs to sit in the massive gap between the two curves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for Rate Limit Design
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Ff2ywwvjgjzhq6vlavatu.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%2Ff2ywwvjgjzhq6vlavatu.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A rate limit of 100 executions per 60 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never blocks legitimate traffic.&lt;/strong&gt; Even a high-volume e-commerce system processing 80 orders per minute sits under the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always catches loops.&lt;/strong&gt; A recursive loop hits 100 executions in under 8 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap between "highest legitimate volume" and "slowest possible loop" is enormous. You don't need machine learning or anomaly detection. You just need basic arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Math
&lt;/h2&gt;

&lt;p&gt;A recursive trigger loop doubles (at minimum) with each iteration. If one trigger execution creates one record, and that record fires one trigger:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Executions (cumulative)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Iteration 1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iteration 2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iteration 3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iteration 10&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iteration 16&lt;/td&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even with network latency and compute overhead slowing each iteration to 100ms, you hit 100 executions in ~7 seconds. With faster execution (10ms per iteration), you hit 100 in under a second.&lt;/p&gt;

&lt;p&gt;Meanwhile, the highest legitimate trigger volume we've seen across our platform is ~80 executions per minute per trigger — and that's a busy e-commerce workspace during a flash sale.&lt;/p&gt;

&lt;p&gt;The gap is 10x-100x. Your rate limit has a lot of room.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Burst Traffic?
&lt;/h2&gt;

&lt;p&gt;The natural objection: "What about a bulk import? A user imports 500 records at once, and each fires a trigger."&lt;/p&gt;

&lt;p&gt;This is a valid concern but a different problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bulk imports via API&lt;/strong&gt; publish a single aggregate event (&lt;code&gt;records_bulk_created&lt;/code&gt;), not 500 individual events. Event-driven triggers don't match on the aggregate event, so they don't fire at all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch operations from compute functions&lt;/strong&gt; do publish individual events. But even 500 trigger executions from a batch operation is a one-time burst, not a sustained loop. If your rate limit window is 60 seconds, the burst registers once. A loop registers continuously.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If batch-triggered functions need to fire triggers&lt;/strong&gt;, the rate limit should be configurable per-trigger. Default 100/60s works for 99% of cases. The 1% that needs more can raise it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Implementing the Test
&lt;/h2&gt;

&lt;p&gt;The simplest implementation is a Redis counter with a TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isWithinRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;triggerId&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`trigger_rate:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;triggerId&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;count&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&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="mi"&gt;60&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&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;That's it. Six lines. The &lt;code&gt;INCR&lt;/code&gt; is atomic (no race conditions across instances), the &lt;code&gt;EXPIRE&lt;/code&gt; handles cleanup, and the threshold separates linear from exponential with a 10x margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond Rate Limiting
&lt;/h2&gt;

&lt;p&gt;Rate limiting is the safety net, not the whole solution. For a complete defense:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Block obvious loops at configuration time.&lt;/strong&gt; When a user creates a trigger on &lt;code&gt;record_created&lt;/code&gt; for collection X, and the function calls &lt;code&gt;api.createRecord('X', ...)&lt;/code&gt;, reject it with a clear error. This is prevention, not detection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Track causality at runtime.&lt;/strong&gt; Propagate a &lt;code&gt;sourceTriggerId&lt;/code&gt; through event chains so you can identify self-loops without waiting for the rate limit to trip. The user gets a "recursive loop detected" message instead of a vague "rate limit exceeded."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limit as the catch-all.&lt;/strong&gt; For cross-trigger chains (A→B→A) and exotic patterns that bypass the first two layers.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We wrote a detailed post about implementing all three layers: &lt;a href="https://medium.com/@olowu.marydan/how-we-stopped-recursive-trigger-loops-from-melting-our-compute-fleet-498a4cb3e5d0" rel="noopener noreferrer"&gt;How We Stopped Recursive Trigger Loops From Melting Our Compute Fleet&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;If your platform has event-driven triggers, ask yourself: can a trigger's output become its own input? If yes, you need loop protection. And the simplest, most reliable loop protection is a rate limit set in the gap between linear user-driven traffic and exponential recursive behavior.&lt;/p&gt;

&lt;p&gt;That gap is enormous. Use it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building event-driven infrastructure? We'd love to hear about your trigger architecture challenges. Reach out on [Twitter/X] @centraliio or drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eventdrivenarchitecture</category>
      <category>recursionprevention</category>
      <category>platformengineering</category>
      <category>ratelimiting</category>
    </item>
  </channel>
</rss>
