<?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: Jancer Lima</title>
    <description>The latest articles on DEV Community by Jancer Lima (@jancera).</description>
    <link>https://dev.to/jancera</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%2F3769134%2Fafdb00bc-a64c-4120-b1c4-47e20f41a780.png</url>
      <title>DEV Community: Jancer Lima</title>
      <link>https://dev.to/jancera</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jancera"/>
    <language>en</language>
    <item>
      <title>Building a RAG Search Engine for an AI Sales Agent: Problems, Iterations, and Real Decisions</title>
      <dc:creator>Jancer Lima</dc:creator>
      <pubDate>Fri, 29 May 2026 00:46:09 +0000</pubDate>
      <link>https://dev.to/jancera/building-a-rag-search-engine-for-an-ai-sales-agent-problems-iterations-and-real-decisions-43l4</link>
      <guid>https://dev.to/jancera/building-a-rag-search-engine-for-an-ai-sales-agent-problems-iterations-and-real-decisions-43l4</guid>
      <description>&lt;p&gt;In early 2024 I was building an AI-driven WhatsApp sales agent that needed to answer customer questions about business products accurately. The agent had to behave like an SDR, qualifying leads, answering product questions, and moving conversations forward.&lt;/p&gt;

&lt;p&gt;The core challenge: how do you give an AI agent accurate, relevant product knowledge when a business has thousands of products and you can't fit them all in a context window?&lt;/p&gt;

&lt;p&gt;This article describes the real decisions and iterations behind the solution, including what failed before we got it right.&lt;/p&gt;




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

&lt;p&gt;Stuffing an entire product catalogue into the system prompt was never viable. Beyond the context window ceiling, sending thousands of products on every request wastes tokens, increases latency, and actively degrades response quality. The model's attention gets diluted across irrelevant products.&lt;/p&gt;

&lt;p&gt;The real problem has two parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What data to include:&lt;/strong&gt; Given a conversation, which products are actually relevant?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to include it:&lt;/strong&gt; Not every message needs product context. Triggering a product search on every turn is wasteful and slow.&lt;/p&gt;

&lt;p&gt;An additional constraint shaped the early architecture: in early 2024, tool calling support across models was inconsistent and unreliable. We could not depend on it as a foundation.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Attempt and Why It Failed
&lt;/h2&gt;

&lt;p&gt;My first approach was to handle the "when to search" problem directly in the system prompt. I instructed the model to return a structured identifier when the conversation required product information, something like &lt;code&gt;["REQUEST_PRODUCTS", "search term"]&lt;/code&gt;. My code would intercept this identifier, run the vector search, and re-call the model passing the conversation history plus the returned products as additional context for generating the next response.&lt;/p&gt;

&lt;p&gt;This worked in theory but fell apart in practice. The main issue was output consistency. The model frequently ignored the required format, adding extra text alongside the identifier or generating a full response instead of the structured output I needed. Since my code was parsing a specific format, any deviation broke the pipeline.&lt;/p&gt;

&lt;p&gt;The fundamental problem was that I was asking a single model call to handle two responsibilities at once: deciding whether a product search was needed and signaling that decision in a machine-readable format. Mixing those concerns into one prompt made the output unreliable.&lt;/p&gt;

&lt;p&gt;This is where classifiers came in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Classifier Architecture
&lt;/h2&gt;

&lt;p&gt;The solution was to break the pipeline into dedicated steps, each with a single responsibility. Instead of asking one model call to decide, search, and respond, I introduced classifiers: small focused AI calls that evaluate one thing and return a structured output.&lt;/p&gt;

&lt;p&gt;The pipeline has three stages: intent detection, search quality evaluation, and response generation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1: Intent Detection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first classifier receives the conversation history and returns a search term if product information is needed, or null if it is not. A single focused question to the model returns a consistent structured output far more reliably than embedding that logic into a generation prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2: Search Quality Evaluation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the vector search returns results, a second classifier evaluates how well those results match the conversation context, returning a precision score between 0 and 1. If the score does not meet the threshold, the pipeline retries with a variation: a shorter message window passed to the first classifier to generate a different search term. The idea is that reducing the conversation history changes the emphasis of the generated search term, introducing variation that can surface better results.&lt;/p&gt;

&lt;p&gt;The retry loop runs for a maximum of 5 attempts. On each retry the message window shrinks by 2 messages, starting from 12 messages and stopping if the window reaches 2 messages or the attempt limit is hit. The best scoring result across all attempts is carried forward regardless of whether the threshold was reached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;best_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;

&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MAX_ATTEMPTS&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;search_term&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intent_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;search_term&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quality_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conversation_history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
        &lt;span class="n"&gt;best_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="n"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stage 3: Response Generation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only at this final stage does the model generate a customer-facing message, now with the highest quality product context available. Separating generation from classification meant each prompt had a single job, which dramatically improved output consistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Search Implementation
&lt;/h2&gt;

&lt;p&gt;With the classifier architecture handling when and what to search, the search itself needed to be fast, accurate, and operationally simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storing products as vectors&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a product catalogue is uploaded, the system generates an embedding for each product, and stores it in PostgreSQL using the pgvector extension. Rather than embedding each field individually, each product is embedded as a single concatenated string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name: {productName}, Description: {productDescription}, Price: {productPrice}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embedding fields individually would have complicated the search without a clear quality benefit. A single embedding per product keeps the search straightforward: one vector comparison per product, one similarity score per result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why pgvector over a dedicated vector database&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a dedicated vector database like Pinecone or Weaviate would have introduced an additional managed service, a separate billing account, and another failure point. At the scale this system operated, none of those tradeoffs were justified. pgvector runs inside the same PostgreSQL instance already handling relational data, meaning one database to back up, monitor, and connect to.&lt;/p&gt;

&lt;p&gt;For systems requiring millions of vectors or sub-millisecond retrieval at high concurrency, a dedicated vector database becomes the right call. This was not that system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The similarity search&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Queries run as cosine similarity searches using pgvector's &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator. The operator returns a cosine distance between 0 and 2. To convert this into a more intuitive similarity score, the search query transforms the result using &lt;code&gt;1 - (embedding &amp;lt;=&amp;gt; query_vector)&lt;/code&gt;, producing a score between -1 and 1 where higher values indicate stronger similarity. The search term generated by the intent classifier is embedded using the same model and format as the stored products, then compared against the catalogue vectors. The top-K results are returned with their similarity scores for the quality classifier to evaluate.&lt;/p&gt;

&lt;p&gt;One known limitation of cosine similarity is poor handling of negation. If a user says they do not want a specific type of product, the search may still return results related to that product because the embedding captures semantic proximity without understanding negation. In practice this was handled at the generation stage, instructing the model to filter out irrelevant results from the context. A more robust solution would involve query rewriting before the vector search, detecting negation in the conversation and reformulating the search term to exclude the unwanted concepts, but this was not implemented in the production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known Limitations
&lt;/h2&gt;

&lt;p&gt;The current implementation has a few limitations worth acknowledging for anyone considering this approach in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding caching:&lt;/strong&gt; Every query generates a new OpenAI API call. At low volume this is negligible, but at scale repeated similar queries would benefit from caching embeddings to reduce both latency and cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingestion pipeline:&lt;/strong&gt; The repository ingests products sequentially on startup for simplicity. The production system handled this differently: businesses uploaded their own product catalogues through the platform, triggering a background ingestion pipeline. The sequential approach is sufficient for local testing but is not representative of how ingestion works at real scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cosine similarity and negation:&lt;/strong&gt; As described in the previous section, the search does not handle negation well. Query rewriting before the vector search is the most promising direction for addressing this, though it was not implemented here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This architecture was shaped by the constraints of early 2024, when reliable tool calling was not something you could build a production system on. The classifier approach solved that constraint but added its own complexity: multiple model calls per turn, a retry loop, and careful prompt engineering to keep each classifier focused.&lt;/p&gt;

&lt;p&gt;Today I would approach this differently. Native tool calling has matured enough to be worth testing as a replacement for the intent detection step. But I do not think classifiers disappear entirely. The quality evaluation loop, where the system iterates toward a better search result rather than accepting the first one, solves a problem that tool calling does not address. The practical answer is probably a hybrid: native tool calls where the model's built-in capabilities are sufficient, and explicit classification steps where output precision matters enough to warrant the extra calls.&lt;/p&gt;

&lt;p&gt;The extracted search implementation this article references is available at &lt;a href="https://github.com/Jancera/rag-search" rel="noopener noreferrer"&gt;github.com/Jancera/rag-search&lt;/a&gt;. It covers the vector storage and similarity search backbone described here. The classifier architecture lives in the production system it was built for.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
    </item>
    <item>
      <title>Why worker pools beat clustering for CPU-Heavy tasks on Node.js</title>
      <dc:creator>Jancer Lima</dc:creator>
      <pubDate>Tue, 05 May 2026 00:15:24 +0000</pubDate>
      <link>https://dev.to/jancera/why-worker-pools-beat-clustering-for-cpu-heavy-tasks-on-nodejs-2eoe</link>
      <guid>https://dev.to/jancera/why-worker-pools-beat-clustering-for-cpu-heavy-tasks-on-nodejs-2eoe</guid>
      <description>&lt;p&gt;Imagine you have a Nodejs server with endpoint that performs heavy CPU operations.&lt;/p&gt;

&lt;p&gt;By default your server runs on a single thread. This means it will freeze depending on the CPU load. If your server has other asynchronous endpoints, for example, to execute database operations, those endpoints would become unresponsive while the heavy load endpoint is processing.&lt;/p&gt;

&lt;p&gt;Our first idea is to create more threads, sending the heavy tasks to be processed in parallel by another CPU core. Once finished, we send the output back to the main thread and return the answer to the client.&lt;/p&gt;

&lt;p&gt;The problem now is that we can not have more threads than available CPU cores (technically we can but it does not make much sense) so we start thinking about using worker pools where we instantiate an fixed amount of workers and reuse them to our desired tasks.&lt;/p&gt;

&lt;p&gt;Now we have a stable structure where we offload CPU intensive tasks to other threads to make the main thread free and available for new requests.&lt;/p&gt;

&lt;p&gt;I've setup a test case where we run our server into a docker container with 2 CPUs and 2Gb of memory. Our server has a root endpoint &lt;code&gt;/&lt;/code&gt; which returns an OK response and a &lt;code&gt;/blocking/:n&lt;/code&gt; endpoint that runs a Fibonacci algorithm. &lt;/p&gt;

&lt;p&gt;I've let &lt;code&gt;n&lt;/code&gt; as a parameter so we can customize how much work we want our server to do &lt;em&gt;(n is the Fibonacci input)&lt;/em&gt;. &lt;/p&gt;

&lt;p&gt;All the source code for the server and the benchmark can be found &lt;a href="https://github.com/Jancera/worker-pools-lab" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I've setup 20k requests on &lt;code&gt;/&lt;/code&gt; and 30 requests on &lt;code&gt;/blocking/35&lt;/code&gt;. It takes approximately 10 seconds to execute and we can analyze the output. &lt;em&gt;Note: The tests were hitting both endpoints simultaneously.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One test was against a server with 1 instance with 2 threads.&lt;/p&gt;

&lt;p&gt;The other test was against a server with 2 instances with 1 thread each.&lt;/p&gt;

&lt;p&gt;The server clustering were made using node &lt;code&gt;cluster&lt;/code&gt; module, the worker pool was using &lt;code&gt;Piscina&lt;/code&gt; which internally uses &lt;code&gt;worker_threads&lt;/code&gt; module and the tests were executed with &lt;code&gt;autocannon&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;2 processes, 1 thread each&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Results for http://localhost:3000/

┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬───────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max   │
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼───────┤
│ Latency │ 0 ms │ 2 ms │ 43 ms │ 50 ms │ 4.17 ms │ 9.12 ms │ 94 ms │
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴───────┘
┌───────────┬────────┬────────┬────────┬────────┬─────────┬────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%    │ 97.5%  │ Avg     │ Stdev  │ Min    │
├───────────┼────────┼────────┼────────┼────────┼─────────┼────────┼────────┤
│ Req/Sec   │ 1,162  │ 1,162  │ 1,926  │ 3,761  │ 2,000.2 │ 744.83 │ 1,162  │
├───────────┼────────┼────────┼────────┼────────┼─────────┼────────┼────────┤
│ Bytes/Sec │ 277 kB │ 277 kB │ 458 kB │ 895 kB │ 476 kB  │ 177 kB │ 277 kB │
└───────────┴────────┴────────┴────────┴────────┴─────────┴────────┴────────┘

Req/Bytes counts sampled once per second.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;of samples: 10
&lt;span class="go"&gt;
20k requests in 10.07s, 4.76 MB read


Results for http://localhost:3000/blocking/35

┌─────────┬────────┬─────────┬─────────┬─────────┬────────────┬────────────┬─────────┐
│ Stat    │ 2.5%   │ 50%     │ 97.5%   │ 99%     │ Avg        │ Stdev      │ Max     │
├─────────┼────────┼─────────┼─────────┼─────────┼────────────┼────────────┼─────────┤
│ Latency │ 276 ms │ 1968 ms │ 8645 ms │ 8645 ms │ 2327.81 ms │ 1998.18 ms │ 8645 ms │
└─────────┴────────┴─────────┴─────────┴─────────┴────────────┴────────────┴─────────┘
┌───────────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐
│ Stat      │ 1%    │ 2.5%  │ 50%   │ 97.5% │ Avg   │ Stdev │ Min   │
├───────────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ Req/Sec   │ 1     │ 1     │ 3     │ 3     │ 2.73  │ 0.62  │ 1     │
├───────────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ Bytes/Sec │ 253 B │ 253 B │ 759 B │ 759 B │ 690 B │ 156 B │ 253 B │
└───────────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘

Req/Bytes counts sampled once per second.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;of samples: 11
&lt;span class="go"&gt;
30 requests in 11.07s, 7.59 kB read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1 process with 2 threads&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Results for http://localhost:3000/

┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬───────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max   │
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼───────┤
│ Latency │ 1 ms │ 2 ms │ 35 ms │ 43 ms │ 4.56 ms │ 7.63 ms │ 61 ms │
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴───────┘
┌───────────┬────────┬────────┬────────┬────────┬──────────┬────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%    │ 97.5%  │ Avg      │ Stdev  │ Min    │
├───────────┼────────┼────────┼────────┼────────┼──────────┼────────┼────────┤
│ Req/Sec   │ 640    │ 640    │ 1,454  │ 3,379  │ 1,818.37 │ 790.84 │ 640    │
├───────────┼────────┼────────┼────────┼────────┼──────────┼────────┼────────┤
│ Bytes/Sec │ 152 kB │ 152 kB │ 346 kB │ 804 kB │ 433 kB   │ 188 kB │ 152 kB │
└───────────┴────────┴────────┴────────┴────────┴──────────┴────────┴────────┘

Req/Bytes counts sampled once per second.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;of samples: 11
&lt;span class="go"&gt;
20k requests in 11.05s, 4.76 MB read


Results for http://localhost:3000/blocking/35

┌─────────┬────────┬─────────┬─────────┬─────────┬────────────┬───────────┬─────────┐
│ Stat    │ 2.5%   │ 50%     │ 97.5%   │ 99%     │ Avg        │ Stdev     │ Max     │
├─────────┼────────┼─────────┼─────────┼─────────┼────────────┼───────────┼─────────┤
│ Latency │ 276 ms │ 2794 ms │ 3861 ms │ 3861 ms │ 2358.17 ms │ 957.99 ms │ 3861 ms │
└─────────┴────────┴─────────┴─────────┴─────────┴────────────┴───────────┴─────────┘
┌───────────┬───────┬───────┬─────────┬─────────┬───────┬───────┬───────┐
│ Stat      │ 1%    │ 2.5%  │ 50%     │ 97.5%   │ Avg   │ Stdev │ Min   │
├───────────┼───────┼───────┼─────────┼─────────┼───────┼───────┼───────┤
│ Req/Sec   │ 2     │ 2     │ 4       │ 4       │ 3.34  │ 0.82  │ 2     │
├───────────┼───────┼───────┼─────────┼─────────┼───────┼───────┼───────┤
│ Bytes/Sec │ 506 B │ 506 B │ 1.01 kB │ 1.01 kB │ 843 B │ 207 B │ 506 B │
└───────────┴───────┴───────┴─────────┴─────────┴───────┴───────┴───────┘

Req/Bytes counts sampled once per second.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;of samples: 9
&lt;span class="go"&gt;
30 requests in 9.02s, 7.59 kB read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We can see that having 1 process with 2 threads gave us better results for both endpoints.&lt;/p&gt;

&lt;p&gt;Looking to the 99th metric, it was 7ms faster for the &lt;code&gt;/&lt;/code&gt; route and 4784ms for the &lt;code&gt;/blocking&lt;/code&gt; endpoint.  &lt;/p&gt;

&lt;p&gt;This shows us that spinning up multiple independent process might seem like a quick scaling fix, but in practice, we waste resources managing process overhead instead of computing actual work. More importantly, a single process with a worker pool keeps the main Event Loop unblocked. It successfully handles all incoming traffic and efficiently distributes the heavy CPu load, resulting in a significantly lower wait times for 99% of our requests.&lt;/p&gt;

&lt;p&gt;Of course we could expand our test scenario and environment looking for more realistic numbers, but that will be a work for another article. &lt;/p&gt;

&lt;p&gt;Thanks for your attention!&lt;/p&gt;

</description>
      <category>devops</category>
      <category>infrastructure</category>
      <category>node</category>
    </item>
    <item>
      <title>Incident Report: Service failure due to storage full</title>
      <dc:creator>Jancer Lima</dc:creator>
      <pubDate>Sat, 18 Apr 2026 01:09:42 +0000</pubDate>
      <link>https://dev.to/jancera/incident-report-service-failure-due-to-storage-full-4a4p</link>
      <guid>https://dev.to/jancera/incident-report-service-failure-due-to-storage-full-4a4p</guid>
      <description>&lt;p&gt;Yesterday, my homelab server suddenly became unresponsive. It started with a flurry of Discord notifications, the universal signal that something has gone seriously wrong.&lt;/p&gt;

&lt;p&gt;I found all services offline. The logs pointed to a primary culprit: a Redis failure, specifically a Server Out of Memory error.&lt;/p&gt;

&lt;p&gt;The core error was: &lt;code&gt;RedisClient::CommandError: MISCONF Errors writing to the AOF file: No space left on device&lt;/code&gt;&lt;br&gt;
My first thought was: Why is AOF even enabled? I turned it on for testing and forgot. My root partition was at 99% capacity, just 270MB remaining out of 24GB.&lt;/p&gt;

&lt;p&gt;Further investigation revealed where the "wasted" space was hiding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PM2 Logs (~3.8GB):&lt;/strong&gt; The process manager was storing massive, unrotated text logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden Caches (~1.5GB):&lt;/strong&gt; Accumulated &lt;code&gt;~/.cache&lt;/code&gt;, &lt;code&gt;~/.npm&lt;/code&gt;, and &lt;code&gt;~/.rvm&lt;/code&gt;source files from multiple builds and deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To get the system breathing again, I performed a quick "surgical" cleaning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PM2 Flush:&lt;/strong&gt; Immediately cleared the massive log files using &lt;code&gt;pm2 flush&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log Truncation:&lt;/strong&gt; Emptied application logs using &lt;code&gt;truncate -s 0 log/*.log&lt;/code&gt; (this clears the content without deleting the file handle).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Pruning:&lt;/strong&gt; Deleted hidden build caches in &lt;code&gt;~/.npm&lt;/code&gt; and &lt;code&gt;~/.cache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journal Vacuum:&lt;/strong&gt; Cleared system logs with &lt;code&gt;journalctl --vacuum-size=500M&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now I had enough space to spin up all process again, but I need to recover Redis since it entered a Read-Only mode to protect data integrity.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fixing the AOF Manifest:&lt;/strong&gt; Because the disk filled during a Redis write, the &lt;code&gt;appendonly.aof.manifest&lt;/code&gt; was corrupted. I fixed it using &lt;code&gt;sudo redis-check-aof --fix&lt;/code&gt; on the manifest file inside &lt;code&gt;/var/lib/redis/appendonlydir/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clearing the MISCONF Lock:&lt;/strong&gt; Even with free space, Redis remained in a "protected" state. I manually overrode this with &lt;code&gt;redis-cli config set stop-writes-on-bgsave-error no&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service Restart:&lt;/strong&gt; Reset the systemd failure counter with &lt;code&gt;systemctl reset-failed redis-server&lt;/code&gt; and restarted the service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that I could successfully restart all services and have everything running. All data inside redis were not critical, so I didn't care about losing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;The failure was a classic case of neglecting "boring" infrastructure: log rotation and disk monitoring. To prevent a repeat performance, I've implemented the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log Management: Installed pm2-logrotate to cap PM2 logs at 10MB per file and limited journald to 500MB globally.&lt;/li&gt;
&lt;li&gt;Next Steps:

&lt;ul&gt;
&lt;li&gt;Expand the VM disk size (24GB is too tight for this stack).&lt;/li&gt;
&lt;li&gt;Set up a cron job for weekly &lt;code&gt;apt autoremove&lt;/code&gt; and cache clearing.&lt;/li&gt;
&lt;li&gt;Implement an automated disk usage alert (likely via Grafana or a simple shell script to Discord).&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Is TLS Enough? A Retrospective on Application-Layer Encryption</title>
      <dc:creator>Jancer Lima</dc:creator>
      <pubDate>Tue, 24 Feb 2026 11:27:35 +0000</pubDate>
      <link>https://dev.to/jancera/is-tls-enough-a-retrospective-on-application-layer-encryption-3759</link>
      <guid>https://dev.to/jancera/is-tls-enough-a-retrospective-on-application-layer-encryption-3759</guid>
      <description>&lt;p&gt;Years ago, I was part of a heated debate that every engineering team eventually faces: &lt;strong&gt;Is standard TLS enough, or do we need custom application-layer encryption?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We were implementing a payment solution. The provider required a backend-to-backend integration, meaning we had to take user credit card data, send it to our server, and then forward it to the provider.&lt;/p&gt;

&lt;p&gt;My argument was that the TLS layer would be enough for it. The rest of the team disagreed. They didn't have a technical counter-argument, it was just a "lack of trust".&lt;/p&gt;

&lt;p&gt;We ended up building a complex Dual-Keypair System:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The app keypair: Used to sign requests so the server could verify the data actually came from our app (Authenticity).&lt;/li&gt;
&lt;li&gt;The server keypair: The app used the server's public key to encrypt the payload, ensuring only our backend could read it (Confidentiality).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It worked, but years later I realized the hidden parts we didn't consider.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scale: We were small then. But if you scale to multiple server instances, you suddenly need a secure way to share those private keys. You've just turned a "payment problem" into a "key management problem".&lt;/li&gt;
&lt;li&gt;The key rotation: What happens if a key is compromised or expires? If you have users on old app versions, you're stuck. You either support legacy keys forever or force your users to update, either options are bad.&lt;/li&gt;
&lt;li&gt;The "hostile" client: We stored a key in the app. But unless you are using the device's hardware Secure Enclave (iOS) or Keystore (Android), your app is a hostile environment. A determined attacker can decompile the code and extract those keys.&lt;/li&gt;
&lt;li&gt;The TLS termination: The only valid concern was where the TLS "ends". If your HTTPS connection terminates at a Load Balancer and the internal traffic to your web server is plain HTTP, you have a gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Unless you are building a banking core or a literal payment gateway, &lt;strong&gt;TLS is enough&lt;/strong&gt;. If you’re worried about the "last mile" inside your VPC, solve that at the infrastructure level. Don’t bake custom crypto into your application logic unless you’re ready to manage the massive operational overhead that comes with it.&lt;/p&gt;

&lt;p&gt;Experience taught me that "Trust issues" should be solved with better infrastructure, not more code.&lt;/p&gt;

</description>
      <category>security</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
