Mastering Redis Streams and DLQ in NestJS with RedisX

Using Redis Streams in NestJS RedisX can be a game changer for anyone building event-driven systems. Today, let’s dig into consumer groups and dead letter queues (DLQ) — two features that can really up your game in creating resilient architectures.

Redis Streams: The Basics

Redis Streams let you handle messages in a scalable way. The cool part? With consumer groups, you can have multiple service instances share the workload. So, each message goes to just one instance in the group. Perfect for load balancing and keeping your services running smoothly.

Getting Hands-On with Consumer Groups

Let’s look at how easy it is to set up a consumer group in NestJS RedisX. With the @StreamConsumer decorator, you can define a consumer that listens to a specific stream and group:

import { Injectable } from '@nestjs/common';
import { StreamConsumer, IStreamMessage } from '@nestjs-redisx/streams';

@Injectable()
export class OrderProcessor {
  @StreamConsumer({
    stream: 'orders',
    group: 'order-processors',
    batchSize: 10,
  })
  async handleOrder(message: IStreamMessage<OrderEvent>): Promise<void> {
    const { orderId } = message.data;
    try {
      await this.fulfillmentService.process(orderId);
      await message.ack();
    } catch (error) {
      await message.reject(error);
    }
  }
}

Tackling Dead Letter Queues (DLQ)

Now, let’s talk about DLQs. They’re essential for dealing with message failures. If a message can’t be processed after a few tries, you don’t want it disappearing into the void. A DLQ ensures these messages are stored for review later — crucial for troubleshooting and making sure nothing slips through the cracks.

Here’s how easy it is to enable DLQ in NestJS RedisX:

new StreamsPlugin({
  consumer: { batchSize: 10, maxRetries: 3 },
  dlq: { enabled: true },
})

Building Resilient Systems

With Redis Streams, consumer groups, and DLQ, you’re well on your way to building systems that are both scalable and fault-tolerant. RedisX takes care of the heavy lifting, letting you zero in on your business logic.

For those hungry for more, it might be worth checking how these patterns play with other RedisX goodies like caching, idempotency, and tracing. All these pieces fit together to form a robust event-driven architecture.

Dive into the documentation for deeper insights on configuration and advanced usage. Want more? Check out our previous posts on Redis Sentinel and Cache Isolation for tips on building scalable applications with RedisX. nestjs redis streams

Using Custom Properties for Dynamic Theme Changes

The Ultimate Guide to Dynamic Theme Switching with CSS Custom Properties

Grab your coffee, pull up a chair, and let’s talk about a feature that almost every client asks for these days: theme switching. Whether it is a sleek dark mode for night owls, a high-contrast mode for accessibility, or a flamboyant “cyberpunk neon” theme for a marketing campaign, modern websites are expected to change their skin on the fly.

If you are still writing separate stylesheets or duplicating thousands of lines of code just to change a few background colors, stop right there. Today, we are going to master the art of dynamic theme switching using CSS Custom Properties. It is clean, incredibly fast, and requires only a tiny pinch of JavaScript to handle the state. Let’s dive in!

How We Suffered Before (The Dark Ages of Theme Switching)

Remember how we used to build dark mode five or ten years ago? It was an absolute horror show. We had two main workarounds, and both of them made us want to question our career choices.

First, there was the Sass Multi-Class Nightmare. We would compile massive CSS files where every selector was prepended with a theme class, like .dark-theme .card .card-title. Not only did this bloat our bundle sizes, but it also made maintenance a living hell. If you want to reminisce about how we managed complex CSS before modern features took over, check out our thoughts on Why Use CSS Nesting Instead of SASS and LESS.

Second, there was the JavaScript Inline-Style Swap. We would write heavy JS scripts that queried every single element on the page and manually swapped inline styles. It caused layout thrashing, horrible lag, and created the dreaded “flash of bright light” when a dark-mode user refreshed the page. It was clumsy, slow, and totally un-semantic.

The Modern Way: Dynamic Runtime Custom Properties

Today, CSS variables do all the heavy lifting for us. Because custom properties are evaluated at runtime and inherit down the cascade, we can redefine them at the root level, and the entire page instantly updates. No layout recalculations, no stylesheet swapping, and absolutely zero CSS duplication.

By defining our design tokens at the :root level and leveraging HTML data-* attributes, we can switch entire visual schemes by changing a single attribute on the <html> element. This approach is highly modular and forms the backbone of modern CSS design systems. In fact, understanding this architecture is why Variables (CSS Variables) Are the Foundation of Scalable Design in any production-ready application.

Let’s look at how we can implement a multi-theme system (Light, Dark, and Cyberpunk) with a butter-smooth transition effect.

Ready-to-Use Code Snippet

Here is a complete, lightweight, and responsive implementation of a dynamic theme switcher. Paste this into your project to see the magic happen instantly.

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dynamic Theme Switcher</title>
  <style>
    /* 1. Define Design Tokens for Light Theme (Default) */
    :root {
      --bg-color: #f4f4f9;
      --card-bg: #ffffff;
      --text-color: #1a1a24;
      --accent-color: #3b82f6;
      --border-color: #e2e8f0;
      --transition-speed: 0.3s;
    }

    /* 2. Redefine Tokens for Dark Theme */
    [data-theme="dark"] {
      --bg-color: #0f172a;
      --card-bg: #1e293b;
      --text-color: #f8fafc;
      --accent-color: #10b981;
      --border-color: #334155;
    }

    /* 3. Redefine Tokens for Cyberpunk Theme */
    [data-theme="cyberpunk"] {
      --bg-color: #120136;
      --card-bg: #03001e;
      --text-color: #00f0ff;
      --accent-color: #ff007f;
      --border-color: #ff007f;
    }

    /* Global styles applying our tokens */
    body {
      background-color: var(--bg-color);
      color: var(--text-color);
      font-family: 'Segoe UI', system-ui, sans-serif;
      margin: 0;
      padding: 2rem;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      /* Smooth transitions for theme switching */
      transition: background-color var(--transition-speed) ease, 
                  color var(--transition-speed) ease;
    }

    .card {
      background-color: var(--card-bg);
      border: 2px solid var(--border-color);
      border-radius: 12px;
      padding: 2rem;
      max-width: 400px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
      transition: background-color var(--transition-speed) ease, 
                  border-color var(--transition-speed) ease;
    }

    h1 {
      margin-top: 0;
      color: var(--accent-color);
    }

    .btn-group {
      display: flex;
      gap: 0.5rem;
      margin-top: 1.5rem;
    }

    button {
      background: var(--accent-color);
      color: var(--bg-color);
      border: none;
      padding: 0.6rem 1.2rem;
      border-radius: 6px;
      cursor: pointer;
      font-weight: bold;
      transition: opacity 0.2s ease;
    }

    button:hover {
      opacity: 0.9;
    }
  </style>
</head>
<body>

  <div class="card">
    <h1>Theme Switcher</h1>
    <p>Experience modern theme switching using CSS Custom Properties. Super light, incredibly fast, and clean.</p>
    
    <div class="btn-group">
      <button onclick="setTheme('light')">Light</button>
      <button onclick="setTheme('dark')">Dark</button>
      <button onclick="setTheme('cyberpunk')">Neon</button>
    </div>
  </div>

  <script>
    function setTheme(themeName) {
      document.documentElement.setAttribute('data-theme', themeName);
      localStorage.setItem('theme', themeName);
    }

    // Restore user preference on load
    const savedTheme = localStorage.getItem('theme') || 'light';
    setTheme(savedTheme);
  </script>

</body>
</html>

Common Beginner Mistakes to Avoid

Even though custom properties make theme switching a breeze, there are a couple of pitfalls that trip up developers new to this workflow:

  • The “Flash of Light Theme” (FOUC): If you apply the theme inside your main JS bundle after the HTML has finished rendering, your dark-mode users will get a blinding flash of light theme on every page load. Always place the tiny theme-checking script inline at the very top of your <head> or immediately after the opening <html> tag so it executes before the page renders.
  • Animating too many properties: While adding transition: all 0.3s to your elements is tempting to animate color changes, it is terrible for performance. It will force the browser to recalculate layouts, rendering paths, and shadows on every hover or scroll. Only transition specific properties like background-color, color, and border-color.
  • Forgetting system preferences: Don’t force users to click a toggle if their operating system already tells you what they prefer. Always check (prefers-color-scheme: dark) using media queries or JavaScript to set your default fallback state.

That is all there is to it! With just a handful of design tokens and a data-attribute switch, you can scale your styling system infinitely without writing duplicate stylesheets ever again.

🔥 We publish more advanced CSS tricks, ready-to-use snippets, and tutorials in our Telegram channel. Subscribe so you don’t miss out!

I built JARVIS OS: 1000+ autonomous AI agents, on-prem, <300ms voice latency — here's the full architecture

I’ve spent the last 3 years building JARVIS OS — a fully autonomous, on-premise AI infrastructure that runs 1000+ autonomous agents simultaneously, processes voice in under 300ms, and costs a fraction of cloud alternatives.

Today I’m sharing the full architecture, the key decisions, and the lessons learned.

→ Live site & full details: jarvis-delmas.netlify.app

What is JARVIS OS?

JARVIS OS is a distributed AI operating system designed to run entirely on your own hardware — no OpenAI, no Azure, no data leaving your infrastructure.

Key production numbers:

  • 1000+ autonomous agents running simultaneously
  • <300ms voice latency (Whisper CUDA optimized)
  • 835 auto-healing pipelines with circuit-breakers
  • 280,741 lines of Python across 60 MIT-licensed repos
  • 12 GPUs in cluster
  • Benchmark: 81.6/100 (record session: 97/100)
  • -72% infrastructure cost vs equivalent cloud setup

The 9-Layer Architecture

Layer 1: Hardware (GPU cluster, NVMe, InfiniBand)
Layer 2: OS + Virtualization (Linux, Docker, CUDA)
Layer 3: LLM Engine (LM Studio, Ollama, multi-model routing)
Layer 4: Memory System (working → episodic → semantic → procedural)
Layer 5: Agent Orchestration (OpenClaw Gateway, 1000+ agents)
Layer 6: MCP Toolkit (88 handlers, 20+ connectors)
Layer 7: Pipeline Engine (835 Domino auto-healing pipelines)
Layer 8: Voice Interface (Whisper → LLM → TTS <300ms)
Layer 9: External APIs (TradeOracle, Telegram, GitHub)

5 Architectural Decisions That Made the Difference

1. On-Premise by Design

Most teams start with cloud and try to migrate later. We started on-prem from day one.

Result: zero cold start, zero API rate limits, GDPR-native.

Cost comparison:

  • Cloud equivalent: €50,000–500,000/year
  • JARVIS OS: one-shot deployment + maintenance

2. Protocol-First with MCP

Instead of direct integrations, everything goes through the Model Context Protocol (MCP).

Our MCP Toolkit has 88 handlers connecting: filesystem, GitHub, Notion, Slack, PostgreSQL, Redis, vector DBs, Telegram, browser automation, and custom CUDA endpoints.

Any new agent instantly has access to all 88 capabilities.

3. 4-Layer Memory Architecture

# Memory hierarchy in JARVIS OS
working_memory    = RedisCache(ttl=3600)           # Current context
episodic_memory   = PostgreSQL(table="episodes")   # Recent events  
semantic_memory   = ChromaDB(collection="knowledge") # Facts & concepts
procedural_memory = FileSystem(path="./skills/")   # Learned skills

The Π-vectorial compression achieves a 15:1 compression ratio — 15x more context in the same token budget.

4. Auto-Healing Pipelines

All 835 pipelines have built-in circuit-breakers and 13 auto-trigger mechanisms.

@circuit_breaker(failure_threshold=3, recovery_timeout=60)
@auto_retry(max_attempts=3, backoff_factor=2)
async def run_pipeline(pipeline_id: str, context: dict):
    # Pipeline execution with automatic recovery
    ...

5. Voice Pipeline Under 300ms

Stack: Whisper (CUDA) → LLM routing → TTS → audio output

Optimizations:

  • CUDA-optimized Whisper with float16 precision
  • Streaming inference (token-by-token TTS)
  • Wake word detection on a separate thread
  • Audio buffer pre-warming

Average benchmark: 247ms end-to-end on P95 GPU.

The Open-Source Stack

LLMs:          Ollama, LM Studio, GGUF models
Orchestration: OpenClaw Gateway (custom, MIT)
Memory:        PostgreSQL + pgvector, ChromaDB, Redis
Voice:         Whisper CUDA, custom TTS pipeline
MCP:           88 custom handlers
Containers:    Docker (10 services), NVIDIA GPU Operator
Monitoring:    Prometheus + Grafana
Languages:     Python (primary), Rust (performance-critical)

All 60 repos available on GitHub under MIT license:
👉 github.com/Turbo31150

Real-World Modules Running on JARVIS OS

  • TradeOracle — 7 LLMs in consensus for crypto/equity signals
  • Healthcare Multi-Agent — FHIR-compatible medical transcription
  • Domino Engine — 835 self-healing data pipelines
  • OpenClaw Gateway — orchestrates 1000+ agents in production

Key Lessons After 3 Years

  1. Start on-prem — cloud migration is 10x harder than building on-prem from day 1
  2. Protocols over integrations — MCP saved us from integration hell
  3. Memory is the hardest problem — 80% of agent failures are memory coherence issues
  4. Voice latency is binary — users accept <300ms, reject >500ms
  5. Auto-healing or nothing — production pipelines need circuit-breakers from day 1

Learn to Build Your Own

If you want to build a similar system, I’ve documented everything:

🎓 Claude Code Mastery — 13 lessons, build your own agent system in 4 weeks

  • Module 1: FREE → your first agent in 30 minutes
  • Bundle M2+M3: €477 early-bird (vs €797)
  • 14-day “Agent or Refunded” guarantee

📚 62 PDF formations — from beginner to JARVIS expert
🚀 Clé-en-main deployment — I deploy on your hardware in 2–8 weeks

👉 jarvis-delmas.netlify.app

Questions? I answer everything in the comments.
GitHub: github.com/Turbo31150 — 60 repos, all MIT

The Research Tool Bottleneck: Why AI Writing Tools Fail Without Better Source Integration

Your AI writes fast, but you spend twice as long verifying sources it never actually checked—here’s why isolated writing tools are costing you more than you realize.

I tracked my own workflow for three weeks in early 2024. For every 1,000 words my AI tool generated, I spent an average of 47 minutes on post-generation fact-checking. That’s not editing for clarity or flow. That’s pure validation labor—opening tabs, cross-referencing claims, hunting down the original studies that the AI confidently cited but apparently hallucinated from whole cloth.

The math is brutal. If you’re producing 5,000 words of AI-assisted content per week, you’re losing nearly four hours to source verification that the tool should have handled before outputting a single sentence.

The Hidden Cost of Source-Blind AI Writing

Most AI writing tools—ChatGPT, Claude in its standard configuration, Jasper, Copy.ai—operate on a fundamental architectural assumption: generate first, validate never.

These models are trained on static datasets with cutoff dates. GPT-4’s training data ends in April 2023. When you ask it to write about current SaaS market trends, drug approval timelines, or recent regulatory changes, it doesn’t fetch anything. It confabulates based on pattern-matching against old data, then presents those confabulations with the same confident prose voice it uses for everything else.

The specific failure mode I see most often is what I call stale citation drift. A model will cite a real study—say, a McKinsey report on AI adoption—but misattribute its findings, mix it with a different year’s data, or cite figures that were later revised. The citation looks legitimate enough that junior writers or time-pressed strategists let it through. Then it ends up in a published piece, a client deliverable, or an internal strategy doc.

A 2023 Stanford study on AI-generated medical information found that large language models produced confidently stated factual errors in approximately 30% of clinical claims. That’s not a niche problem. That’s a systemic one affecting every domain where accuracy matters.

The hidden cost isn’t just your time. It’s the organizational trust deficit that accumulates when AI-assisted work keeps requiring correction cycles. After a few rounds of “the AI got this wrong again,” stakeholders stop trusting the outputs entirely—and your productivity gains evaporate.

How Integrated Tools Differ: Perplexity + Claude vs. ChatGPT + Manual Switching

The first generation of “research-aware” AI tools tried to solve this with a bolted-on approach: give users a search bar next to their chat window and hope they’d manually feed context in. That workflow is awkward, slow, and still dependent on the user knowing what to verify.

Perplexity represents a genuinely different architecture. It runs a retrieval step before generation, pulling live web results and grounding its responses in those sources. Every claim comes with a citation that links to an actual current page. When I asked Perplexity to summarize the current state of EU AI Act implementation in March 2024, it returned a response that accurately reflected the February 2024 regulatory updates—something ChatGPT got substantially wrong in the same test.

The practical difference: Perplexity’s outputs required about 12 minutes of verification per 1,000 words in my tracking period, compared to 47 minutes for ChatGPT. That’s a 74% reduction in verification time.

But Perplexity has a weakness: its prose quality is functional, not polished. It summarizes and cites well but doesn’t craft arguments or structure long-form content effectively.

This is where a Perplexity + Claude pipeline starts making sense. Use Perplexity for the research and validation layer—get your current facts, cited sources, and data points. Then hand that structured research brief to Claude with explicit instructions to draft from the provided sources only, flagging anything it would normally generate from training data alone.

I tested this combination against pure ChatGPT across 10 technical articles on cloud infrastructure pricing. The Perplexity + Claude workflow produced content that required an average of 1.3 revision rounds before publication. ChatGPT alone required 3.1 revision rounds. That’s a difference of roughly 90 minutes per article at my revision pace—time I now spend on actual strategic work.

The key insight is that you’re not replacing a writing tool with a better writing tool. You’re separating the research and generation functions and optimizing each independently.

Building a Custom Research-to-Draft Pipeline: API Stacking for Real-Time Data Validation

If you’re a technical writer or content strategist who can tolerate a bit of setup, you can build a pipeline that makes the Perplexity + Claude combo look manual by comparison.

Here’s the architecture I’m currently running for a SaaS client’s content operation:

Step 1: Query decomposition via Claude API. When a content brief arrives, I pass it to Claude first—but only for task decomposition, not drafting. The prompt instructs it to break the topic into 8-12 specific factual claims that need current validation. For an article on “enterprise AI adoption barriers,” it might return claims like “current percentage of Fortune 500 companies with deployed AI systems” or “average AI project failure rate in 2023-2024.”

Step 2: Parallel source retrieval via Perplexity API + Exa. I run each factual claim as a separate query through Perplexity’s API (available on their Pro plan) and cross-reference high-stakes claims through Exa (formerly Metaphor), a search API designed specifically for AI retrieval use cases. Exa is particularly good at finding recent academic papers and industry reports that general web search misses.

Step 3: Source validation and contradiction flagging. A simple Python script compares the retrieved sources for the same claim. If two sources contradict each other, or if a source is older than 18 months, the claim gets flagged for human review before it ever reaches the drafting stage.

Step 4: Research-grounded drafting via Claude API. The validated claim set, with source URLs and key quotes, goes to Claude with a strict system prompt: draft from these sources, quote statistics only from the provided data, and use a [NEEDS SOURCE] tag for any claim you want to include that isn’t in the research package.

The full pipeline, on a 1,500-word article, takes about 8 minutes of automated processing and 15 minutes of human review of flagged items. My verification time dropped from 47 minutes to approximately 18 minutes—and the 18 minutes is now focused on genuinely ambiguous or high-stakes claims, not hunting down whether some statistic was made up.

The setup cost is real: roughly 6 hours to build the Python scripts and prompt templates, plus API costs that run about $0.80-$1.20 per article. For any operation producing more than 10 articles per month, the time savings make this trivially cost-positive within the first two weeks.

Benchmarking Accuracy Rates: What Actually Reduces Revision Rounds

I want to be specific here because the AI content space is full of vague claims about “improved accuracy.” Let me share what I’ve actually measured.

Across 60 technical articles produced between January and April 2024, I tracked three variables: factual error rate per article (errors caught before publication), revision rounds required, and total time from brief to publication-ready draft.

Pure ChatGPT-4 (no retrieval augmentation): Average 4.2 factual errors per article, 3.1 revision rounds, 3.8 hours total time including verification.

Perplexity for research + ChatGPT-4 for drafting: Average 1.8 factual errors per article, 2.0 revision rounds, 2.9 hours total time.

Perplexity for research + Claude-3-Opus for drafting: Average 1.1 factual errors per article, 1.4 revision rounds, 2.4 hours total time.

Custom API pipeline (as described above): Average 0.6 factual errors per article, 1.2 revision rounds, 1.8 hours total time.

The counterintuitive finding: the model you use for drafting matters less than the quality of the research layer you feed it. Moving from ChatGPT to Claude without improving the research input reduced errors by only 18%. Adding Perplexity to either model reduced errors by 57-74%. The research integration is doing most of the work.

This has an important implication for how you should prioritize tool investments. If you’re paying for a premium writing-focused AI subscription and skipping retrieval integration, you’re optimizing the wrong variable.

For content strategists specifically, the revision round metric is the one that matters most for team productivity. Every additional revision round typically represents a 45-90 minute overhead when you factor in stakeholder review cycles. Cutting from 3.1 to 1.2 revision rounds isn’t a marginal improvement—it’s the difference between a content operation that scales and one that stays perpetually understaffed.

The Future: Agent-Based Research Assistants That Cite as They Write

The tools I’ve described so far still require deliberate orchestration. You’re building the pipeline, managing the steps, deciding when to switch between tools. The next evolution removes that scaffolding.

Agent-based research assistants are systems that autonomously decide when to search, what to verify, and how to integrate sources—without waiting for explicit user instructions. Several of these are already in early deployment.

Bing Copilot is the most widely accessible current example. When writing a research memo, it will spontaneously pause drafting to verify a statistic, pull a more recent source, and update its claim—all within a single generation stream. The experience is qualitatively different from any tool I’ve described above.

Elicit takes a more academic approach, running automated literature searches and synthesizing findings from actual papers. For technical writers covering research-heavy topics, it’s already replacing manual PubMed trawling.

The most compelling demo I’ve seen recently came from a prototype using AutoGen (Microsoft’s multi-agent framework) where one agent was dedicated to continuous source validation while another drafted. Every claim the drafting agent produced was checked by the validation agent before appearing in the output. The resulting document had inline citations that were generated during writing, not appended afterward.

This architecture—agents that cite as they write rather than write and then cite—will define the next generation of professional AI writing tools. We’re probably 12-18 months from this being a polished commercial product rather than a sophisticated prototype.

But there’s a structural reason current tools won’t get there on their own: it requires inference-time search access, which adds latency and cost that single-product tools have been reluctant to build in by default. The companies that crack this will be ones that treat search infrastructure as core product architecture, not an add-on.

The endgame for source-blind AI writing tools isn’t gradual improvement. It’s obsolescence. Content operations that built their workflows around ChatGPT’s raw generation speed in 2023 are already seeing the productivity ceiling. The tools that integrate retrieval deeply will outcompete on quality metrics that clients and stakeholders actually track.

Here’s the one thing I’d recommend doing this week: run your last five AI-generated pieces through a manual fact-check and actually count the errors per piece. Not impressionistically—count them. Write down the number.

If it’s above 2 per article, you have a measurement-backed reason to restructure your tool stack. Start with the Perplexity + Claude combination before you build anything custom. Run it for two weeks, track your revision rounds, and compare the number to your baseline.

The pipeline investment only makes sense if you have data showing the problem is real in your specific workflow. But for every technical writer I’ve talked to who’s done this audit, the number has been surprising—and the case for changing their workflow has been immediate.

Follow for more practical AI and productivity content.

Building a Serverless Image Processing Pipeline on AWS

Modern applications increasingly rely on images and media content, from e-commerce product uploads to social media platforms and digital archives. However, processing large volumes of images efficiently, securely, and at scale remains a challenge for many businesses. Traditional infrastructure often struggles with storage limitations, slow processing speeds, and high operational costs.

AWS provides a powerful set of services that make it possible to build fully automated, scalable image processing systems without managing servers.

Understanding Serverless Image Processing Architecture

A serverless image processing pipeline allows users to upload images, automatically process them (resize, compress, analyze), and store the results without manual intervention or server management.

One of the key advantages of this architecture is that it eliminates infrastructure complexity while ensuring high scalability and reliability.

You can explore AWS serverless architecture concepts here:
AWS Serverless Computing

Upload and Storage Using Amazon S3

The first step in any image processing system is secure file storage. AWS provides a highly scalable solution through Amazon S3, which allows users to upload images of any size and format.

Amazon S3 Storage Service

When a user uploads an image, it is stored in an S3 bucket. This storage system is designed to handle millions of files while maintaining durability and availability across regions.

Beyond storage, S3 also acts as a trigger source, automatically initiating processing workflows whenever a new image is uploaded.

Automated Processing with AWS Lambda

Once an image is uploaded, the system needs to process it automatically. This is where AWS Lambda becomes essential.

AWS Lambda Compute Service

Lambda allows developers to run backend code without managing servers. In an image processing pipeline, Lambda functions are triggered whenever a new file is added to S3.

These functions can:

  • Resize images into multiple resolutions
  • Compress files to reduce storage size
  • Convert image formats (e.g., PNG to JPEG)
  • Apply watermarks or branding
  • Validate file types and quality

This automation ensures that images are processed instantly and consistently.

Event-Driven Architecture with Amazon EventBridge

To build a fully scalable system, events must be managed efficiently. AWS EventBridge enables event-driven workflows that connect different services seamlessly.

Amazon EventBridge

For example, when an image is uploaded to S3, an event is generated that triggers a Lambda function. This decoupled architecture ensures flexibility, scalability, and fault tolerance.

Event-driven systems are especially useful for applications that handle large volumes of unpredictable traffic.

Metadata Storage Using DynamoDB

After processing images, it is important to store metadata such as file size, resolution, upload time, and processing status.

Amazon DynamoDB

DynamoDB provides a fast, scalable NoSQL database that can store and retrieve metadata efficiently. This allows applications to quickly query processed images without scanning entire storage systems.

Businesses benefit from:

  • Fast data retrieval
  • Low-latency performance
  • Automatic scaling
  • High availability

Real-World Use Cases

Serverless image processing pipelines are widely used in:

  • E-commerce platforms for product image optimization
  • Social media applications for media uploads
  • Healthcare systems for medical imaging analysis
  • Content management systems for automated media formatting

These systems improve performance while reducing infrastructure costs and development complexity.

Benefits of Serverless Image Processing on AWS

This architecture provides several advantages for modern applications:

First, it significantly reduces operational overhead since there is no need to manage servers or scaling manually.

Second, it improves performance by processing images in real time as they are uploaded.

Third, it ensures cost efficiency because users only pay for actual processing time and storage used.

Finally, it provides high scalability, allowing systems to handle sudden spikes in uploads without performance issues.

Conclusion

Building a serverless image processing pipeline on AWS demonstrates how modern cloud architecture can simplify complex workflows while maintaining high performance and scalability.

By combining services like Amazon S3, AWS Lambda, EventBridge, and DynamoDB, developers can create powerful, automated systems that handle large-scale image processing efficiently.

As digital content continues to grow, serverless architectures will play an increasingly important role in enabling fast, cost-effective, and scalable media processing solutions.

Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe sent one webhook. Your database has three orders. What happened?

This is not a hypothetical. I’ve seen it in production — on vatnode.dev, on pikkuna.fi, on pi-pi.ee. The Stripe webhook hits your endpoint, your server takes 800ms to respond, Stripe times out and queues a retry. Meanwhile your server did complete the work. Now you have a duplicate. Then another one arrives 30 minutes later.

The naive implementation — parse the JSON, run your business logic, return 200 — fails in ways that only show up under real traffic. Here’s what production actually looks like.

Why the Naive Implementation Breaks

Stripe’s retry policy is more aggressive than most developers expect. If your endpoint returns anything other than a 2xx status code, or takes longer than 30 seconds to respond, Stripe retries. The schedule: immediately, then at 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and so on — up to 72 hours and roughly 15–18 total attempts.

That means if your server returned 500 at 9 AM on Monday due to a database hiccup, Stripe will still be trying to deliver the same event on Tuesday morning. If your server is back up, it will process the event — possibly creating a duplicate subscription, a duplicate order, or sending a duplicate email to your customer.

The naive handler looks like this:

// app/api/webhooks/stripe/route.ts — DON'T do this
export async function POST(request: Request) {
  const body = await request.json(); // Wrong: parsed body won't verify
  const event = body as Stripe.Event;

  if (event.type === "payment_intent.succeeded") {
    await createOrder(event.data.object); // No idempotency check
  }

  return new Response("ok", { status: 200 });
}

Three problems here: no signature verification, no protection against replay attacks, and synchronous processing that blocks the response while your business logic runs. If createOrder takes 3 seconds and Stripe’s timeout is strict, you’ll get retries even when the order was created successfully.

name=”How to handle Stripe webhooks reliably in production”
totalTime=”PT3H”
tools={[
“Next.js 16”,
“TypeScript”,
“Stripe SDK”,
“BullMQ”,
“ioredis”,
“Drizzle ORM”,
“PostgreSQL”,
]}
steps={[
{
name: “Verify the signature using raw bytes”,
text: “Use request.arrayBuffer() — not request.json() — to get the raw body before passing it to stripe.webhooks.constructEvent. Parsing the body first changes the bytes and makes signature verification always fail.”,
},
{
name: “Implement dual-layer idempotency”,
text: “Store processed Stripe event IDs in PostgreSQL as the source of truth. Add a Redis pre-check layer for sub-millisecond lookups on hot paths. Return 200 immediately when a duplicate is detected.”,
},
{
name: “Enqueue the event with BullMQ”,
text: “Return 200 from the HTTP handler within 50ms by enqueuing to BullMQ using event.id as the jobId for deduplication. Never run business logic synchronously inside the webhook handler.”,
},
{
name: “Process events in a separate worker”,
text: “Run a BullMQ worker with concurrency 5 that handles payment_intent.succeeded, customer.subscription.deleted, and invoice.payment_failed. Double-check idempotency inside the worker to cover edge cases like mid-job restarts.”,
},
{
name: “Wrap critical handlers in transactions”,
text: “Use a database transaction for payment_intent.succeeded so that partial writes never leave inconsistent state. Send confirmation emails outside the transaction — a failed email shouldn’t roll back the order.”,
},
{
name: “Test with Stripe CLI locally”,
text: “Use stripe listen –forward-to localhost:3000/api/webhooks/stripe and stripe trigger payment_intent.succeeded to replay events. Verify idempotency by re-sending the same event ID and confirming the duplicate response.”,
},
]}
/>

Idempotency — The Foundation

slug=”api-integrations”
text=”Need a production-grade Stripe webhook pipeline — idempotency, BullMQ, signature verification, retry handling — wired into your SaaS or e-commerce stack? This is what I build.”
/>

Before any queue, before any worker, you need idempotency: the guarantee that processing the same event twice produces the same result as processing it once.

Stripe makes this straightforward — every event has a unique id field (e.g., evt_1OqXyz...). Store this ID when you process the event. On subsequent attempts, check the store first and short-circuit.

I use PostgreSQL for durable idempotency keys and Redis for a fast pre-check layer. Here’s the Drizzle ORM schema:

// packages/db/schema/webhook-events.ts
import { pgTable, text, timestamp, jsonb } from "drizzle-orm/pg-core";

export const webhookEvents = pgTable("webhook_events", {
  id: text("id").primaryKey(), // Stripe event ID — evt_1OqXyz...
  type: text("type").notNull(), // payment_intent.succeeded, etc.
  processedAt: timestamp("processed_at").notNull().defaultNow(),
  payload: jsonb("payload").notNull(),
});

And the idempotency check — run this before any business logic:

// lib/webhook-idempotency.ts
import { db } from "@/packages/db";
import { webhookEvents } from "@/packages/db/schema/webhook-events";
import { eq } from "drizzle-orm";
import { redis } from "@/lib/redis"; // ioredis instance

export async function isAlreadyProcessed(eventId: string): Promise<boolean> {
  // Fast path: Redis check first (sub-millisecond)
  const cached = await redis.get(`webhook:processed:${eventId}`);
  if (cached) return true;

  // Slow path: database check (handles Redis eviction or restarts)
  const existing = await db
    .select({ id: webhookEvents.id })
    .from(webhookEvents)
    .where(eq(webhookEvents.id, eventId))
    .limit(1);

  if (existing.length > 0) {
    // Restore to Redis cache so future checks are fast
    await redis.set(`webhook:processed:${eventId}`, "1", "EX", 86400 * 7);
    return true;
  }

  return false;
}

export async function markAsProcessed(
  eventId: string,
  type: string,
  payload: unknown
): Promise<void> {
  // Write to DB first — this is the source of truth
  await db.insert(webhookEvents).values({
    id: eventId,
    type,
    payload,
  });

  // Then cache in Redis for fast future lookups
  await redis.set(`webhook:processed:${eventId}`, "1", "EX", 86400 * 7);
}

Signature Verification in Next.js App Router

Here’s the subtle thing that breaks almost every Next.js webhook tutorial: request.json() returns a parsed object. Stripe’s signature verification requires the raw bytes of the original request body. Once parsed, the signature check will always fail.

In the Pages Router, you’d disable bodyParser. In the App Router, you use request.arrayBuffer():

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { isAlreadyProcessed } from "@/lib/webhook-idempotency";
import { webhookQueue } from "@/lib/webhook-queue";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-01-27.acacia",
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  // arrayBuffer() gives us raw bytes — required for signature verification
  const rawBody = await request.arrayBuffer();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // Convert ArrayBuffer to Buffer for the Stripe SDK
    event = stripe.webhooks.constructEvent(Buffer.from(rawBody), signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Check idempotency before doing anything else
  const alreadyProcessed = await isAlreadyProcessed(event.id);
  if (alreadyProcessed) {
    // Return 200 so Stripe stops retrying — this is intentional
    return NextResponse.json({ received: true, duplicate: true });
  }

  // Enqueue the event — don't process synchronously
  await webhookQueue.add(
    event.type,
    { event },
    {
      jobId: event.id, // BullMQ deduplicates by jobId within the active window
      attempts: 5,
      backoff: { type: "exponential", delay: 5000 },
    }
  );

  // Return immediately — Stripe considers this success
  return NextResponse.json({ received: true });
}

Two things to notice. First, the idempotency check runs before enqueuing — so if Stripe retries and the job is still in the queue (not yet processed), you return 200 and BullMQ’s jobId deduplication handles the rest. Second, the endpoint returns immediately after enqueuing. This response time is typically under 50ms, well within Stripe’s timeout window.

BullMQ Queue Architecture

Processing webhooks synchronously in the HTTP handler means your response time is tied to every downstream API call — CRM updates, email sends, database writes. One slow dependency and you’re getting retries.

The right architecture: webhook endpoint enqueues, a separate worker processes.

// lib/webhook-queue.ts
import { Queue } from "bullmq";
import { redis } from "@/lib/redis";

export const webhookQueue = new Queue("stripe-webhooks", {
  connection: redis,
  defaultJobOptions: {
    attempts: 5,
    backoff: {
      type: "exponential",
      delay: 5000, // 5s, 10s, 20s, 40s, 80s
    },
    removeOnComplete: { count: 1000 }, // Keep last 1000 for debugging
    removeOnFail: false, // Keep failed jobs in DLQ for inspection
  },
});
// workers/stripe-webhook.worker.ts
import { Worker, Job } from "bullmq";
import Stripe from "stripe";
import { redis } from "@/lib/redis";
import { isAlreadyProcessed, markAsProcessed } from "@/lib/webhook-idempotency";
import { handlePaymentSucceeded } from "./handlers/payment-succeeded";
import { handleSubscriptionDeleted } from "./handlers/subscription-deleted";
import { handleInvoicePaymentFailed } from "./handlers/invoice-payment-failed";

const worker = new Worker(
  "stripe-webhooks",
  async (job: Job<{ event: Stripe.Event }>) => {
    const { event } = job.data;

    // Double-check idempotency inside the worker — edge case: worker restart mid-job
    const alreadyProcessed = await isAlreadyProcessed(event.id);
    if (alreadyProcessed) {
      return { skipped: true, reason: "duplicate" };
    }

    switch (event.type) {
      case "payment_intent.succeeded":
        await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
        break;

      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;

      case "invoice.payment_failed":
        await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
        break;

      default:
        console.log(`Unhandled webhook type: ${event.type}`);
        return { skipped: true, reason: "unhandled_type" };
    }

    // Mark processed only after the handler succeeds
    // If the handler throws, BullMQ retries the job — we don't mark it done
    await markAsProcessed(event.id, event.type, event.data.object);

    return { processed: true };
  },
  {
    connection: redis,
    concurrency: 5,
  }
);

worker.on("failed", (job, err) => {
  // After all retries exhausted, alert your team
  console.error(`Webhook job ${job?.id} failed permanently:`, err);
});

The dead letter queue behavior is built into BullMQ — failed jobs (after all retries) stay in the failed state. I keep a Telegram alert on the failed event so I know immediately when something needs manual intervention.

Handling the Key Events

payment_intent.succeeded — the most critical handler. Wrap it in a database transaction so partial writes don’t leave inconsistent state:

// workers/handlers/payment-succeeded.ts
import Stripe from "stripe";
import { db } from "@/packages/db";
import { orders, users } from "@/packages/db/schema";
import { eq } from "drizzle-orm";

export async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent): Promise<void> {
  const customerId = paymentIntent.customer as string;
  const metadata = paymentIntent.metadata;

  await db.transaction(async (tx) => {
    await tx.insert(orders).values({
      stripePaymentIntentId: paymentIntent.id,
      customerId,
      amount: paymentIntent.amount,
      currency: paymentIntent.currency,
      status: "paid",
    });

    if (metadata.plan) {
      await tx
        .update(users)
        .set({ plan: metadata.plan, planActivatedAt: new Date() })
        .where(eq(users.stripeCustomerId, customerId));
    }
  });

  // Send confirmation email outside the transaction — failure here
  // doesn't roll back the order; it just retries the email separately
  await sendOrderConfirmation({ paymentIntentId: paymentIntent.id });
}

customer.subscription.deleted — downgrade the user, don’t delete their data. Keep at least 30 days of data post-cancellation. Stripe fires this for both immediate cancellations and end-of-period ones — check cancel_at_period_end to differentiate.

invoice.payment_failed — send a dunning email, don’t immediately revoke access. Stripe’s Smart Retries will attempt the charge again. Give the customer a grace period to update their payment method.

Testing with Stripe CLI

# Forward all events to your local Next.js dev server
stripe listen 
  --forward-to localhost:3000/api/webhooks/stripe

# Trigger specific events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

For testing idempotency: copy the event ID from the CLI output and call your endpoint twice with the same payload — the second call should return { received: true, duplicate: true } within milliseconds.

Gotchas That Cost Me Real Time

request.json() silently breaks signature verification. The raw body and the serialized JSON aren’t byte-for-byte identical. Always use request.arrayBuffer(). This is the most common issue I see in Next.js webhook implementations.

BullMQ jobId deduplication only covers active jobs. If a job is completed or failed, BullMQ will accept a new job with the same ID. That’s why the database idempotency check is still necessary — it covers retries arriving weeks after the original was processed.

STRIPE_WEBHOOK_SECRET differs between environments. The CLI secret (whsec_... with CLI prefix) is different from the dashboard secret. Use separate environment variables for each environment.

Never trust event.data.object amounts without checking currency. EUR amounts are in cents (100 = €1.00). JPY has no minor unit (100 = ¥100). Always pair the amount with the currency field when storing.

What This Looks Like in Production

On vatnode.dev, webhooks hit the endpoint and return within 40–60ms. The BullMQ worker processes the actual subscription logic asynchronously, with 5 concurrent workers handling bursts. Zero duplicate subscriptions created since launch.

On pikkuna.fi, the same architecture drives the full order pipeline — Stripe fires the webhook, the worker triggers Zoho CRM, PostNord shipment creation, Netvisor invoice, and Mailgun confirmation email in sequence. The webhook endpoint returns 200 within 50ms; the full chain completes in under 2 minutes.

If you’re building a SaaS or e-commerce platform with Stripe, you’ll hit exactly these problems — usually at the worst moment, like during a product launch or after a server restart.

I’ve built reliable Stripe integrations across several production systems, from subscription SaaS (vatnode.dev) to high-volume international e-commerce (pikkuna.fi). Once the webhook pipeline is solid, the order automation layer on top becomes straightforward — see how the full e-commerce order pipeline works.

If you need a senior developer who can own the payment infrastructure end-to-end — get in touch. I’m available for API integration and e-commerce development projects and long-term engagements.

Related projects: Pikkuna E-commerce Platform — full order pipeline with Stripe, Zoho CRM, and PostNord integration. Vatnode VAT validation SaaS — subscription billing where this webhook architecture is in production.

Better Loading Buttons in Angular Material v22

A simple reports page with a list of reports and a download button for each report

Angular Material v22 adds a small but surprisingly useful improvement to buttons: built-in progress indicator support. Instead of manually swapping button text with a spinner and dealing with layout jumpiness, we can let the Material button directive manage the loading UI for us. In this post, I’ll show you the old manual approach, why it creates a small UX issue, and how Angular Material v22 cleans it up.

A Simple Example

Let’s start with a simple reports page:

A simple reports page with a list of reports and a download button for each report

We have a list of reports, and each report has a “Download” button using the matButton directive:

<section>
  <ul class="report-list">
    @for (report of reports(); track report.id) {
      <li class="report-row">
        <div class="report-info">
          <span class="report-name">{% raw %}{{ report.name }}{% endraw %}</span>
          <span class="report-meta">{% raw %}{{ report.date }} · {{ report.size }}{% endraw %}</span>
        </div>

        <button
          matButton="outlined"
          [disabled]="downloadingId() !== null"
          (click)="download(report)">
          Download
        </button>
      </li>
    }
  </ul>
</section>

Nothing unusual here.

But the loading UI is where this usually gets a little clunky.

The Old Way: Swap the Label for a Spinner

Before this new Angular Material v22 feature, a common approach was to conditionally replace the button label with a spinner.

First, we need to import the progress spinner in our component:

import { MatProgressSpinner } from '@angular/material/progress-spinner';

@Component({
  selector: 'app-report-list',
  templateUrl: './report-list.component.html',
  styleUrl: './report-list.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatButton, MatProgressSpinner],
})
export class ReportListComponent {
  // ...
}

Then we can update the button template to include the progress spinner conditionally when the report is downloading and the label when it isn’t:

<button
  matButton="outlined"
  [disabled]="downloadingId() !== null"
  (click)="download(report)">
  @if (downloadingId() === report.id) {
    <mat-progress-spinner
      mode="indeterminate"
      [diameter]="20"
      aria-label="Downloading"
    />
  } @else {
    Download
  }
</button>

Since this download doesn’t expose a real percentage, mode="indeterminate" is the right fit here.

The diameter keeps the spinner small enough to fit inside the button, and the aria-label gives assistive technologies meaningful loading-state text since the visible label is being replaced.

And this works fine:

A reports page with a list of reports and a download button for each report. When the report is downloading, we show a spinner. When it isn't, we show the label.

When the report is downloading, we show a spinner.

When it isn’t, we show the label.

But there’s still a UX issue.

The button shrinks when the label disappears and only the spinner remains.

That’s because we’re swapping the button’s content entirely.

The text has one width, the spinner has another, and the button resizes to fit whatever is currently rendered.

It’s not broken, but it feels a little janky.

And we had to write the conditional content logic ourselves.

If you’re serious about leveling up your Angular skills, there’s now an official certification path worth exploring.

Built with input from Google Developer Experts, it focuses on real-world Angular knowledge.

👉 Details here: https://bit.ly/4tfqleD



The Angular Material v22 Way: Use showProgress and progressIndicator

Angular Material v22 gives us another option.

Instead of swapping the label and spinner manually, both can live inside the button at the same time:

<button
  matButton="outlined"
  [showProgress]="downloadingId() === report.id"
  [disabled]="downloadingId() !== null"
  (click)="download(report)">
  <mat-progress-spinner
    progressIndicator
    mode="indeterminate"
    [diameter]="20"
    aria-label="Downloading"
  />
  Download
</button>

There are two important pieces here.

First, the button gets the showProgress input, which is new in Angular Material v22:

[showProgress]="downloadingId() === report.id"

This tells the matButton directive when the progress UI should be shown.

In this case, we only want progress on the button for the report currently being downloaded.

Then, the spinner gets the progressIndicator attribute:

<mat-progress-spinner progressIndicator />

This marks the spinner as the button’s projected progress indicator.

So instead of us writing an @if block to decide what appears, Angular Material controls that progress indicator slot for us.

Why This Feels Better

A reports page with a list of reports and a download button for each report. When the report is downloading, we show a spinner. When it isn't, we show the label, this time using the new showProgress input.

The important detail is that the normal button content stays in the layout, even when the progress indicator is visible.

So the button still knows how wide the “Download” label is, even while the spinner is being displayed.

That means we avoid the width jump caused by replacing the content completely.

The end result is a loading button that feels stable instead of jumpy.

This Does Not Have to Be a Material Spinner

One nice part of this API is that progressIndicator is a projection slot.

That means the projected content doesn’t have to be mat-progress-spinner specifically.

You could use a custom loading element if that fits your design system better.

The main thing is to make sure the progress indicator still communicates meaningful loading-state information, especially when the visible button label is hidden or visually replaced.

Cleaner Loading Buttons in Angular Material

This is one of those Angular Material updates that isn’t huge, but it’s immediately useful.

The old approach works, but it usually means manually swapping content and accepting small layout issues.

With showProgress and progressIndicator, Angular Material gives us a built-in pattern for loading buttons that feels more polished with less template logic.

Want to Go Deeper With Modern Angular?

Angular’s newest APIs are changing the way we build.

If you’re ready to go deeper with one of the biggest shifts in modern Angular, my Signal Forms course will help you get comfortable with the new forms model.

You can access it either directly or through YouTube membership, whichever works best for you:

👉 Buy the course

👉 Get it with YouTube membership

Additional Resources