<?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: Kenneth Mckrola</title>
    <description>The latest articles on DEV Community by Kenneth Mckrola (@mackoverflow).</description>
    <link>https://dev.to/mackoverflow</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%2F3891762%2F6c560b7e-4be6-467c-b325-df54e6e8cda4.jpg</url>
      <title>DEV Community: Kenneth Mckrola</title>
      <link>https://dev.to/mackoverflow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mackoverflow"/>
    <language>en</language>
    <item>
      <title>Laravel Bus Batching for Clean Data Processing</title>
      <dc:creator>Kenneth Mckrola</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:52:48 +0000</pubDate>
      <link>https://dev.to/mackoverflow/laravel-bus-batching-for-clean-data-processing-1oeh</link>
      <guid>https://dev.to/mackoverflow/laravel-bus-batching-for-clean-data-processing-1oeh</guid>
      <description>&lt;h2&gt;
  
  
  The Feature
&lt;/h2&gt;

&lt;p&gt;Over 10k subscribed users are paying to have updates to contact lists in a CRM application. In a time interval set between each day and every 7 days, we will push updated and new contacts from our lead sourcing to the CRM.\&lt;br&gt;
These datasets are on average in the hundreds of contacts per user, but for some users reach into the thousands.\&lt;br&gt;
If any failures occur in the process of creating a prospect record, we want to know which records fail in the source data. If there is additional user setup that is required as well before we can begin pushing these contacts to the users' CRM, we also need to know about that. If a failure should arise, we need to be aware that the failed data could be corrected and should be retried at a future time interval. Once a failure threshold has occurred for a single contact record, we can move it out of the process.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Path Forward
&lt;/h2&gt;

&lt;p&gt;To begin, we create batches of 1k contacts each to process at a time. These batches may consist entirely of leads that belong to the same user, or there may be multiple users in ownership. The trick is that we know which user's specific contact list settings they are identified by. Each user may have multiple lists that leads get assigned to, each with it's own delivery settings and preferences (delivery time, intervals, text vs. email, etc.). Validated data is proceeded to send to the CRM application, and failed data is returned early without blocking the rest of the user's leads or other users' leads in the same batch.&lt;/p&gt;
&lt;h2&gt;
  
  
  Challenge - Framing Multi-service architecture
&lt;/h2&gt;

&lt;p&gt;4 separate services were involved in this feature.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Snowflake as lead delta data store&lt;/li&gt;
&lt;li&gt;Laravel scheduler/base queue worker (kernel driver)&lt;/li&gt;
&lt;li&gt;Main Laravel web service (HTTP Clients &amp;amp; bus batch )&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CRM web service (HTTP Server)\&lt;br&gt;
\&lt;br&gt;
The lead list deltas are pulled in the scheduler each hour, 1k records at a time (1 batch). From there, we begin validation and begin mapping the associated user's list settings, pulling them from the Laravel web service where they live, and checking if the lead deltas are due to sync based on their CRM-sync timing configuration. Validation on correct user contact info, push a bulk POST request to the web service.\&lt;br&gt;
The bulk endpoint in the web service handles additional pre-flight validation, determines users CRM account, and pushes their leads to be created as prospects there.&lt;/p&gt;
&lt;h3&gt;
  
  
  Constraint: Throttling requests per second to CRM app
&lt;/h3&gt;

&lt;p&gt;The main CRM destination limits to 10 requests/second per user, which could mean an entire batch with 1000 leads under a single would the request to block for almost 2 minutes, with many cases taking on average 1 minute. Not acceptable waiting on this during the Laravel base queue worker's handler, and introduces potential network timeout or connection dropped issues when many of these requests are made in quick succession. Failure rate could be expected to climb and look messy.\&lt;br&gt;
\&lt;br&gt;
At this point, I begin to explore an async approach, putting the leads into a job, and making the request from the queue worker service to the web service a fire-and-forget request. If the request goes through, we return a 202 response, and the queue worker moves on to the next batch of 1k records to process.&lt;/p&gt;
&lt;h3&gt;
  
  
  Constraint: Updating lead status independently of batch
&lt;/h3&gt;

&lt;p&gt;The batch update functionality is designed to update all leads in the batch via this Snowflake proc call: &lt;code&gt;LEADS.UPDATEBATCH($batchId, 'STATUS', [$exceptionLeadId1, $exceptionLeadId2, ... ])&lt;/code&gt;. This means if some leads fail during pre-flight validation, and others continue, we have to leave those in processing as 'LOCKED', while keeping track of which failed prior to our job dispatch, as the job handler won't have access to the context of the request lifecycle. However, I also needed to be mindful of the leads that could fail to be created in the CRM in that request itself.&lt;/p&gt;

&lt;p&gt;To account for our 2 constraints, 2 solutions were in order&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use Bus Batching to process CRM requests per lead.&lt;/li&gt;
&lt;li&gt;Use Redis to cache lead ids that have failed across jobs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In order to account for delayed requests, tracking all failed jobs across job dispatches that don't have information about one another, and eventually updating all job statuses, we create one job per request to be made, and dispatch all via &lt;code&gt;Bus::batch()&lt;/code&gt; \&lt;br&gt;
\&lt;br&gt;
It ends up looking like this:&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="nc"&gt;Bus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobs&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;allowFailures&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;finally&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="kt"&gt;Batch&lt;/span&gt; &lt;span class="nv"&gt;$batch&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;$batchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
        &lt;span class="nv"&gt;$failedNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;\Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Failed id for batchId: &lt;/span&gt;&lt;span class="nv"&gt;$batchId&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="nv"&gt;$failedNum&lt;/span&gt;&lt;span class="s2"&gt;"&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="nf"&gt;updateSnowflakeJobStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SYNCED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the usage above of &lt;code&gt;Cache::get() / Cache::forget()&lt;/code&gt;. Upon completion of all jobs, we log the number of failed jobs, post an update to the Snowflake proc to list all non-failed job ids as synced (successful) and then clear the cache. Inside of the job handler, we store failed job ids under the correct batch this way:&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="nv"&gt;$cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dl_sync:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$batchId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:failed_ids"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion &amp;amp; Reflection
&lt;/h2&gt;

&lt;p&gt;Batching jobs through Laravel's Bus isn't always the right tool. For a handful of jobs, or when the caller needs to report status synchronously in the same request, it's overkill. It earns its keep when hundreds or thousands of jobs are in play and the client doesn't need the full outcome inside a single request lifecycle.&lt;/p&gt;

&lt;p&gt;That's exactly my situation. The upstream service that pulls tens to hundreds of thousands of contacts out of Snowflake finishes its part in under a minute, hands the records to the API, and gets a &lt;code&gt;202 Accepted&lt;/code&gt; back immediately. The API runs a quick pre-flight (missing tokens, missing profiles, missing list settings), then dispatches one batch holding a single-contact job for every record that survived. &lt;code&gt;allowFailures()&lt;/code&gt; means one bad push to the CRM service doesn't cancel the rest of the list.&lt;/p&gt;

&lt;p&gt;The Redis cache is what makes the reporting honest. A &lt;code&gt;Batch&lt;/code&gt; knows how many jobs failed, not which contacts failed, and the &lt;code&gt;finally&lt;/code&gt; callback can't see individual job payloads. So each job's &lt;code&gt;failed()&lt;/code&gt; hook appends its identifier to a per-job cache key under a short lock, the pre-flight failures are seeded into that same key before dispatch, and &lt;code&gt;finally&lt;/code&gt; reads the key once, calls the Snowflake stored procedure to mark the job &lt;code&gt;SYNCED&lt;/code&gt; with the failed id list, and forgets the key. One write to Snowflake per job, covering both synchronous and asynchronous failures.&lt;/p&gt;

&lt;p&gt;Two tradeoffs I'm living with: the one-hour TTL on that key bounds how long a batch can run before failures silently disappear, and with &lt;code&gt;tries = 1&lt;/code&gt; a transient CRM timeout counts as a permanent failure for that contact. Both are acceptable at today's volume, and both are easy to tune when they stop being.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>snowflake</category>
      <category>queue</category>
    </item>
    <item>
      <title>Easily benchmark all your app's endpoints at once</title>
      <dc:creator>Kenneth Mckrola</dc:creator>
      <pubDate>Wed, 29 Apr 2026 21:09:11 +0000</pubDate>
      <link>https://dev.to/mackoverflow/easily-benchmark-all-your-apps-endpoints-at-once-2fod</link>
      <guid>https://dev.to/mackoverflow/easily-benchmark-all-your-apps-endpoints-at-once-2fod</guid>
      <description>&lt;p&gt;Most "load tests" in real codebases are a &lt;code&gt;curl&lt;/code&gt; pasted into a Slack thread. Someone runs it before a release, eyeballs the latency, and we ship. There's nothing version-controlled, nothing repeatable, and the next person to touch the service has no idea which endpoints are actually fast paths.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://benchmarkr-1.onrender.com" rel="noopener noreferrer"&gt;benchmarkr&lt;/a&gt; is a powerful and easy-to-use CLI and MCP tool that fixes that part of the workflow specifically. The thing I want to talk about in this post is the piece that makes it click: a YAML config that lives in your repo and describes every endpoint you care about, the same way a &lt;code&gt;package.json&lt;/code&gt; describes your dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;

&lt;p&gt;First, install benchmarkr cli if you haven't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap mack-overflow/tap
brew &lt;span class="nb"&gt;install &lt;/span&gt;benchmarkr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for Homebrew, or for Debian&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [trusted=yes] https://apt.fury.io/mack-overflow/ /"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/benchmarkr.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;benchmarkr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(More installation guides available &lt;a href="https://benchmarkr-1.onrender.com/docs" rel="noopener noreferrer"&gt;here&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Next, run &lt;code&gt;benchmarkr endpoints init&lt;/code&gt; in your project root and you get a &lt;code&gt;benchmarkr.yaml&lt;/code&gt; you can commit:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;endpoints&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;list-users&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${API_BASE:-http://localhost:8080}/users&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer ${API_TOKEN}&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;duration_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&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;search-users&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${API_BASE}/users/search&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test"&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50"&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;duration_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&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;create-order&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${API_BASE}/orders&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer ${API_TOKEN}&lt;/span&gt;
      &lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ABC-123"&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;duration_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Env var substitution.&lt;/strong&gt; &lt;code&gt;${API_BASE}&lt;/code&gt; and &lt;code&gt;${API_BASE:-default}&lt;/code&gt; work the way they do in shell. A sibling &lt;code&gt;.env&lt;/code&gt; file is auto-loaded but never overrides what's already in the environment, so the same file works on a laptop, in CI, and in staging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defaults travel with the endpoint.&lt;/strong&gt; &lt;code&gt;create-order&lt;/code&gt; runs at concurrency 2 for 10 seconds because that's what makes sense for a write path. &lt;code&gt;list-users&lt;/code&gt; runs at concurrency 10. You set this once in the file you already review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery walks up from CWD.&lt;/strong&gt; Run the CLI from any subdirectory and it finds the file, like &lt;code&gt;git&lt;/code&gt; does.&lt;/li&gt;
&lt;/ul&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%2Fnw0c3qoj334x8hhqusls.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%2Fnw0c3qoj334x8hhqusls.png" alt="CLI endpoints list output" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running one endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="nt"&gt;-e&lt;/span&gt; list-users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Saved defaults apply. Any flag you pass on the command line wins; headers and params are merged. So when you're poking at production specifically, you can do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="nt"&gt;-e&lt;/span&gt; list-users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"X-Trace: debug-2026-04-28"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…without editing the committed file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running all of them
&lt;/h2&gt;

&lt;p&gt;This is where the YAML pays for itself. Because every endpoint is named and self-describing, you can hand the entire file to the CLI in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That walks every endpoint in &lt;code&gt;benchmarkr.yaml&lt;/code&gt; in succession, applying each endpoint's saved defaults (concurrency, duration, headers, body — the whole config). Between runs you get a &lt;code&gt;[i/N] &amp;lt;name&amp;gt;&lt;/code&gt; header so it's obvious where you are; live p50/p95/p99 streams in for the active endpoint and a final summary prints when it finishes. &lt;code&gt;--all&lt;/code&gt; is mutually exclusive with &lt;code&gt;--url&lt;/code&gt; and &lt;code&gt;--endpoint&lt;/code&gt;, and any flags you do pass (e.g. &lt;code&gt;--store&lt;/code&gt;, &lt;code&gt;--json&lt;/code&gt;, &lt;code&gt;--rate-limit&lt;/code&gt;) apply to every run in the sweep.&lt;/p&gt;

&lt;p&gt;For CI, this collapses the workflow step to one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/perf.yml&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;Benchmark every endpoint&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;API_BASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://api.staging.example.com&lt;/span&gt;
    &lt;span class="na"&gt;API_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.STAGING_API_TOKEN }}&lt;/span&gt;
    &lt;span class="na"&gt;BENCH_CLOUD_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.BENCHMARKR_TOKEN }}&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;benchmarkr run --all --store --json &amp;gt; perf-results.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; with &lt;code&gt;--all&lt;/code&gt; emits an array — one entry per endpoint, with the same &lt;code&gt;result&lt;/code&gt; shape as a single run — so you can pipe it straight into a regression check or upload it as a CI artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list-users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stop_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"30.001s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12483&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p50_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p95_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p99_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errors_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search-users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stop_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15.002s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4127&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p50_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p95_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p99_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errors_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create-order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stop_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.001s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;312&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p50_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p95_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"p99_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;121&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errors_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're not maintaining a separate list of "endpoints to benchmark" in your CI workflow and a list in your config. There's one list. Add a new endpoint to &lt;code&gt;benchmarkr.yaml&lt;/code&gt; in the same PR that adds the route, and the next CI run picks it up automatically — no workflow edits, no shell loop to babysit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round-tripping with the cloud dashboard
&lt;/h2&gt;

&lt;p&gt;The CLI gives you fast feedback. The dashboard gives you the long view — historical p95 charts, regression detection across versions, the kind of thing that's painful to wire up yourself.&lt;/p&gt;

&lt;p&gt;The newest piece is import/export, so the YAML in your repo and the endpoints in the dashboard stay in sync without anyone having to maintain both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Export from the dashboard.&lt;/strong&gt; Open any endpoint and click &lt;strong&gt;Export&lt;/strong&gt; for YAML or JSON. Or click &lt;strong&gt;Export all&lt;/strong&gt; in the endpoints nav to dump every endpoint to one file you can drop into a fresh repo.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Import to the dashboard.&lt;/strong&gt; Click &lt;strong&gt;Import&lt;/strong&gt;, pick a &lt;code&gt;benchmarkr.yaml&lt;/code&gt;, and endpoints upsert by &lt;code&gt;(user, name)&lt;/code&gt;. If the config changed, a new version is recorded — so you get a history of how each endpoint's load shape evolved.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&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%2Fb2klkwgl4a48htpx3lg2.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%2Fb2klkwgl4a48htpx3lg2.png" alt="Import in Benchmarkr UI Nav" width="795" height="74"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fochel2kdfj1i2qvfo1up.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%2Fochel2kdfj1i2qvfo1up.png" alt="Export as YAML in UI" width="798" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmf8nwq6sw8oxba12jbr.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%2Fqmf8nwq6sw8oxba12jbr.png" alt="endpoint history" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A workflow I've been using:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define endpoints in &lt;code&gt;benchmarkr.yaml&lt;/code&gt;, commit them.&lt;/li&gt;
&lt;li&gt;CI runs the loop above on every PR with &lt;code&gt;--store&lt;/code&gt; and the cloud token, persisting results to the dashboard.&lt;/li&gt;
&lt;li&gt;Open the endpoint in the dashboard to see the trend line for that endpoint across the last N PRs.&lt;/li&gt;
&lt;li&gt;If somebody adds an endpoint via the dashboard UI for ad-hoc poking, &lt;strong&gt;Export&lt;/strong&gt; → drop the file into the repo → it's now part of the CI matrix.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A note on the cloud dashboard
&lt;/h2&gt;

&lt;p&gt;The cloud platform is currently in &lt;strong&gt;closed beta&lt;/strong&gt;. We're planning to open it up to the public on a per-token basis in &lt;strong&gt;spring 2026&lt;/strong&gt; — if you'd like access at launch, you can &lt;a href="https://benchmarkr-1.onrender.com/waitlist" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The CLI itself is open source and works without the cloud — &lt;code&gt;benchmarkr run&lt;/code&gt;, the YAML config, and even local result persistence don't require an account or a token. The dashboard, history charts, version pinning, and import/export are the parts gated behind beta access for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth doing
&lt;/h2&gt;

&lt;p&gt;The shift that matters isn't "run benchmarks in CI" — plenty of tools do that. It's having a single, reviewable file that says &lt;em&gt;here are this service's endpoints and how we expect them to behave under load&lt;/em&gt;, sitting next to the code in the same PR.&lt;/p&gt;

&lt;p&gt;Once that file exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New endpoints get a perf budget at the same moment they get a route handler.&lt;/li&gt;
&lt;li&gt;Reviewers can see in the diff that a new write path is being benchmarked at concurrency 2, not 100, and push back if that's wrong.&lt;/li&gt;
&lt;li&gt;CI gets a free regression signal across every endpoint, not just the one someone remembered to add to a script.&lt;/li&gt;
&lt;li&gt;The dashboard gives you the historical view without anyone manually re-entering endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo already describes your API. This is just letting it benchmark itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;benchmarkr is open source — &lt;code&gt;brew install mack-overflow/tap/benchmarkr&lt;/code&gt; or grab it from &lt;a href="https://benchmarkr-1.onrender.com" rel="noopener noreferrer"&gt;benchmarkr&lt;/a&gt;. Cloud dashboard beta access opens publicly per-token in spring 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>performance</category>
      <category>devops</category>
      <category>go</category>
    </item>
    <item>
      <title>Benchmarkr - cURL, built for concurrency, MCP, and real performance benchmarking</title>
      <dc:creator>Kenneth Mckrola</dc:creator>
      <pubDate>Wed, 22 Apr 2026 06:48:19 +0000</pubDate>
      <link>https://dev.to/mackoverflow/benchmarkr-curl-built-for-concurrency-mcp-and-real-performance-benchmarking-17c6</link>
      <guid>https://dev.to/mackoverflow/benchmarkr-curl-built-for-concurrency-mcp-and-real-performance-benchmarking-17c6</guid>
      <description>&lt;p&gt;&lt;a href="https://benchmarkr-1.onrender.com" rel="noopener noreferrer"&gt;Link to benchmarkr homepage&lt;/a&gt;&lt;br&gt;
What if cURL let you easily run concurrent requests and benchmark your&lt;br&gt;
endpoints? Where you had an executable that exports tools for your coding&lt;br&gt;
agents (Claude, Cursor) via MCP, streams live performance updates, and&lt;br&gt;
auto-exports benchmark runs to JSON or SQL?&lt;/p&gt;

&lt;p&gt;Development speed is increasing rapidly. Testing and benchmarking are&lt;br&gt;
becoming even more crucial aspects of development for testing AI-generated&lt;br&gt;
and AI-assisted code. &lt;strong&gt;Benchmarkr&lt;/strong&gt; lets you easily orchestrate performance&lt;br&gt;
testing on your API endpoints — whether you're catching regressions, sanity-&lt;br&gt;
checking a refactor, or letting an agent validate the code it just wrote&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5p6w5nk7dy48ep4t7sli.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%2F5p6w5nk7dy48ep4t7sli.png" alt="Hero image of benchmarkr run" width="799" height="588"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why another HTTP tool?
&lt;/h2&gt;

&lt;p&gt;There's no shortage of load-test tools — &lt;code&gt;hey&lt;/code&gt;, &lt;code&gt;ab&lt;/code&gt;, &lt;code&gt;wrk&lt;/code&gt;, &lt;code&gt;bombardier&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;k6&lt;/code&gt;. They're great at what they do, but they live in a pre-agent world:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No live feedback&lt;/strong&gt; — you wait 30 seconds, then read a wall of text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No structured persistence&lt;/strong&gt; — you pipe to a file, grep, repeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No agent integration&lt;/strong&gt; — your coding agent can't call them without
shelling out and parsing free-form output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Benchmarkr is a single Go binary that does three things well with little out of the box configuration required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Runs concurrent HTTP benchmarks with live metrics in the terminal.&lt;/li&gt;
&lt;li&gt;Stores runs as JSON files, Postgres, or MySQL — configured once, reused
forever.&lt;/li&gt;
&lt;li&gt;Ships an MCP server so Claude Code, Cursor, and any other MCP-compatible
agent can benchmark your endpoints by name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://github.com/Mack-Overflow/api-bench" rel="noopener noreferrer"&gt;Github Repo&lt;/a&gt;&lt;br&gt;
&lt;a href="https://benchmarkr-1.onrender.com/docs" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS / Linux&lt;/span&gt;
brew tap mack-overflow/tap
brew &lt;span class="nb"&gt;install &lt;/span&gt;benchmarkr

&lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [trusted=yes] https://apt.fury.io/mack-overflow/ /"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/benchmarkr.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;benchmarkr

&lt;span class="c"&gt;# RHEL / Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/yum.repos.d/benchmarkr.repo &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
[benchmarkr]
name=Benchmarkr
baseurl=https://yum.fury.io/mack-overflow/
enabled=1
gpgcheck=0
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;yum &lt;span class="nb"&gt;install &lt;/span&gt;benchmarkr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Or grab a binary from the &lt;a href="https://github.com/Mack-Overflow/api-bench/releases/tag/v0.1.2" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; if you'd&lt;br&gt;
rather skip a package manager.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your first benchmark
&lt;/h2&gt;

&lt;p&gt;The smallest useful command:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;benchmarkr run --url https://api.example.com/health&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That fires a single worker for 10 seconds at a GET endpoint. You'll see requests, errors, and P50/P95 update live, then a final summary with throughput, latency percentiles, status-code breakdown, response sizes, and cache hit/miss counts.&lt;/p&gt;

&lt;p&gt;Add concurrency and duration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://api.example.com/users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 50 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--duration&lt;/span&gt; 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;POST with headers and a body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://api.example.com/users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer tok_xxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"test"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rate-limit so you don't accidentally DDoS staging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://api.example.com/search &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--duration&lt;/span&gt; 20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rate-limit&lt;/span&gt; 100    &lt;span class="c"&gt;# max 100 req/s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bypass the CDN cache to measure origin latency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmarkr run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://cdn.example.com/asset.js &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cache-mode&lt;/span&gt; bypass &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--duration&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The MCP server — letting agents benchmark for you
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most excited about. Install the MCP companion binary:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;brew install mack-overflow/tap/benchmarkr-mcp&lt;/code&gt;&lt;br&gt;
Then wire it into your agent. For Claude Code, drop a .mcp.json in&lt;br&gt;
your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"benchmarkr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"benchmarkr-mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a short CLAUDE.md so the agent prefers it over reinventing the wheel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Benchmarking&lt;/span&gt;

Use the benchmarkr MCP tools (run_benchmark, get_benchmark_status,
stop_benchmark, list_endpoints) for all API benchmarking tasks.
Do not install or use external tools like hey, ab, or bombardier.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor is the same config under ~/.cursor/mcp.json plus a .cursorrules.&lt;/p&gt;

&lt;p&gt;Now you can say things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Benchmark the /api/users endpoint at 20 concurrent workers for 30
seconds and tell me if the P95 is above 200ms."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the agent calls the run_benchmark tool directly, reads the&lt;br&gt;
structured result, and answers in the context of your actual code. No more&lt;br&gt;
"let me write a shell script for you" dance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fedy55l177m6bjfy96ssq.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%2Fedy55l177m6bjfy96ssq.png" alt="Claude benchmarkr mcp run" width="616" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is heading
&lt;/h2&gt;

&lt;p&gt;A few things on the roadmap I'm looking for feedback on:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;compare_endpoints&lt;/code&gt; — run two URLs side-by-side and diff the metrics.&lt;br&gt;
&lt;code&gt;regression_test&lt;/code&gt; — assert against a previous run's P95/P99 and return a pass/fail the agent can reason about.&lt;br&gt;
Scenario files — YAML-defined multi-step flows (login → fetch → post) instead of single-URL runs.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>performance</category>
      <category>go</category>
      <category>cli</category>
    </item>
  </channel>
</rss>
