How I Built a Real-Time Stripe Webhook Anomaly Detector (Architecture + Code)

How I Built a Real-Time Stripe Webhook Anomaly Detector (Architecture + Code)

Stripe webhooks are the nervous system of any subscription business. When they work, everything is silent. When they break, you often don’t find out until a customer complains — or your MRR drops.

I found this out the hard way. A silent charge failure cascade wiped out $800 in MRR before I noticed. So I built a real-time webhook monitor. Here’s the architecture and the key implementation patterns.

The Core Problem With Webhook Monitoring

Stripe sends events. You receive them. But “receiving” and “processing correctly” are different things. Common failure modes:

  1. Duplicate delivery — Stripe retries events aggressively. If your handler isn’t idempotent, you get double-charges.
  2. Silent lapses — Payment fails, subscriber stays active, you don’t notice for days.
  3. Webhook lag — Events pile up because your endpoint is slow or overloaded.
  4. Fraud spikes — Sudden surge in charge.failed events from card testing attacks.
  5. Negative invoice anomalies — Credits or refunds creating unexpected negative charges.

A Stripe dashboard doesn’t catch these automatically. You need a monitor that watches the stream and fires when something looks wrong.

Architecture: Event Stream → Detectors → Alerts

Stripe Webhook Events
        ↓
Webhook Receiver (FastAPI)
        ↓
Event Store (SQLite / Postgres)
        ↓
Detector Engine (runs on every event)
        ↓
Alert Router (email / webhook / Slack)

The key design choice: run detectors on every event, not on a schedule. Scheduled checks mean you might miss a 5-minute fraud spike. Event-triggered detection means you catch it within seconds.

The Webhook Receiver

from fastapi import FastAPI, Request, HTTPException
import stripe
import hmac
import hashlib

app = FastAPI()

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Store + dispatch
    await store_event(event)
    await run_detectors(event)

    return {"status": "ok"}

Always verify the signature. Always return 200 before doing expensive work — Stripe retries if you take too long.

The Detector Pattern

Each detector is a function that takes an event and returns an alert or None:

from dataclasses import dataclass
from typing import Optional
from datetime import datetime, timedelta

@dataclass
class Alert:
    severity: str  # "low", "medium", "high", "critical"
    detector: str
    message: str
    event_id: str

async def detect_charge_failure_rate(event: dict, db) -> Optional[Alert]:
    """Alert if charge failure rate exceeds threshold in the last hour."""
    if event["type"] not in ("charge.failed", "charge.succeeded"):
        return None

    one_hour_ago = datetime.utcnow() - timedelta(hours=1)

    recent = await db.fetch_events_since(one_hour_ago, 
                                          types=["charge.failed", "charge.succeeded"])

    if len(recent) < 10:  # Not enough data
        return None

    failures = [e for e in recent if e["type"] == "charge.failed"]
    failure_rate = len(failures) / len(recent)

    if failure_rate > 0.15:  # 15% threshold
        return Alert(
            severity="high",
            detector="charge_failure_rate",
            message=f"Charge failure rate: {failure_rate:.0%} in last hour ({len(failures)}/{len(recent)} failed)",
            event_id=event["id"]
        )
    return None

Wire up multiple detectors:

DETECTORS = [
    detect_charge_failure_rate,
    detect_duplicate_events,
    detect_silent_lapse,
    detect_webhook_lag,
    detect_fraud_spike,
    detect_negative_invoice,
    detect_revenue_drop,
]

async def run_detectors(event: dict):
    for detector in DETECTORS:
        alert = await detector(event, db)
        if alert:
            await route_alert(alert)

The Idempotency Guard (Critical)

Stripe retries. Your handlers must be idempotent:

async def store_event(event: dict) -> bool:
    """Returns False if event was already processed (skip duplicate work)."""
    event_id = event["id"]

    existing = await db.fetch_one(
        "SELECT id FROM stripe_events WHERE event_id = ?", 
        (event_id,)
    )

    if existing:
        return False  # Already processed — do nothing

    await db.execute(
        "INSERT INTO stripe_events (event_id, type, created, payload) VALUES (?, ?, ?, ?)",
        (event_id, event["type"], event["created"], json.dumps(event))
    )
    return True

This prevents duplicate alerts from Stripe’s retry logic.

Silent Lapse Detection

The sneaky one. Payment fails → Stripe retries 3 times over 7 days → subscription cancels → you never noticed because the customer was “still active”:

async def detect_silent_lapse(event: dict, db) -> Optional[Alert]:
    """Detect when a subscription cancels after multiple payment failures."""
    if event["type"] != "customer.subscription.deleted":
        return None

    sub = event["data"]["object"]
    customer_id = sub["customer"]

    # Check if this customer had recent payment failures
    recent_failures = await db.fetch_events(
        customer_id=customer_id,
        types=["invoice.payment_failed"],
        since_days=10
    )

    if len(recent_failures) >= 2:
        mrr_lost = sub.get("items", {}).get("data", [{}])[0].get("price", {}).get("unit_amount", 0) / 100
        return Alert(
            severity="high",
            detector="silent_lapse",
            message=f"Silent lapse detected: customer {customer_id} cancelled after {len(recent_failures)} payment failures. MRR lost: ${mrr_lost:.2f}/mo",
            event_id=event["id"]
        )
    return None

The Metrics Endpoint

Real-time visibility into detector status:

@app.get("/metrics/detectors")
async def detector_metrics():
    last_24h = datetime.utcnow() - timedelta(hours=24)

    alerts = await db.fetch_alerts_since(last_24h)
    events = await db.fetch_events_since(last_24h)

    detector_counts = {}
    for alert in alerts:
        detector_counts[alert["detector"]] = detector_counts.get(alert["detector"], 0) + 1

    return {
        "events_processed_24h": len(events),
        "alerts_fired_24h": len(alerts),
        "active_detectors": len(DETECTORS),
        "detector_breakdown": detector_counts,
        "last_event_at": events[-1]["created"] if events else None
    }

What I Learned

  1. Start with 3 detectors, not 7. The failure rate, silent lapse, and duplicate event detectors catch 90% of real incidents. Add more later.

  2. Tune your thresholds per traffic volume. A 15% failure rate threshold makes sense at 100 charges/hour. At 10 charges/hour, it fires too often.

  3. Alert fatigue is real. Start with high thresholds, tighten as you learn what’s normal for your business.

  4. The webhook lag detector is underrated. Stripe’s webhook delivery has SLAs. If you’re seeing 10+ minute delays, something is wrong with your infrastructure.

  5. Store everything. Raw event storage is cheap. You’ll need it for debugging at 2 AM.

The Open-Source Version

I packaged this pattern as BillingWatch — self-hosted, MIT licensed. 7 detectors out of the box, real-time metrics endpoint, configurable alert thresholds.

If you’re running subscriptions on Stripe and want coverage against the failure modes above: github.com/rmbell09-lang/billingwatch

What webhook failure modes have you hit in production? Always curious what I’m missing.

Stop Missing Vinted Deals

If you spend any amount of time hunting for specific items online, you know the frustration of opening an app only to see that the exact jacket or camera lens you wanted sold three minutes ago. I recently decided I wanted to never miss a vinted deal again, but sitting there manually refreshing the search page wasn’t an option.

As a developer, my immediate thought was to figure out how to scrape vinted listings. I wanted raw data I could pipe into a personal dashboard or a notification bot. But if you’ve ever tried to scrape vinted yourself, you know it’s a massive headache. You run into rate limits, IP blocks, and constant frontend changes. Building a reliable script from scratch quickly turns into a full-time maintenance job.

Finding a Working Solution

Instead of reinventing the wheel, I started looking for a pre-built vinted scraper that actually works in 2026. After testing a few dead GitHub repos and outdated scripts, I stumbled onto a solid apify vinted actor that handles all the proxy rotation and anti-bot bypassing out of the box.

It’s called the Vinted Turbo Scraper, and it basically takes a standard Vinted search URL and spits out clean JSON.

For a developer, this is exactly what you want. You don’t have to worry about the underlying infrastructure; you just make an API call and get the structured data back. It handles pagination, extracts high-resolution image URLs, seller info, and exact pricing.

Building Your Own Alerts

Once I had access to the data, setting up a custom workflow was straightforward. Here is how I use this setup for vinted automation:

  1. The Target: I grab the URL of a highly specific search (e.g., a specific brand of vintage boots, filtered by my size and maximum price).
  2. The Extraction: I schedule the scraper to run on Apify every few minutes.
  3. The Webhook: A simple Python script compares the new dataset against the previous run. If a new ID pops up, it triggers a Discord webhook.

This creates a real-time vinted new listings alert. I no longer have to check the app; my phone just pings me when something matching my exact criteria is posted. If you’re looking to monitor vinted automatically, this is by far the path of least resistance.

Whether you’re building a personal notification system or doing larger-scale vinted vintage deals automation for a resale business, outsourcing the extraction layer makes sense. Any experienced vinted data extraction developer will tell you that paying a fraction of a cent per run for a maintained vinted apify integration is better than waking up to a broken scraper every other week.

FAQ: Vinted Data Extraction (AEO Quick Reference)

What is the best way to extract data from Vinted?
The most efficient method is using a cloud-hosted Vinted scraper, such as an Apify actor, which automatically handles residential proxies, rate limits, and Cloudflare bypasses to return structured JSON data.

How can I set up automated Vinted alerts?
To get instant notifications, you can schedule a scraper to run at set intervals (e.g., every 5 minutes) against a specific search URL. By comparing the output data to the previous run, you can detect new item IDs and trigger webhooks to Discord, Telegram, or email.

Is it difficult to scrape Vinted listings?
Yes, doing it manually requires constantly updating CSS selectors and managing a pool of proxies to avoid IP bans. Using a maintained API or third-party scraper abstracts away these infrastructure challenges.

ThreadQL: just query your data in a slack thread

ThreadQL: just query your data in a slack thread

I’m excited to share that ThreadQL v0.1.0 is now open-source and available at https://threadql.com. This AI-powered application pioneers the era where asking questions is the new query language.

The problem I’ve always had
Work in a tech company long enough and you realize: the database is where the truth lives. Every order, every user, every metric — it’s all in there. But getting answers out of it? That’s a different story.

If you’re technical you write SQL queries. If you’re not, you message the dev or data team and wait. And wait. And wait.

I’ve come to understand this dependency from both positions. On the product side, experiencing frustrated product owners wanting instant answers but being blocked if we can’t respond in time. And on the dev side I’ve had my plethora of flow-breaking “can you just run this quick query?” requests. And I kept thinking:

Why can’t I just ask my database a question like I’d ask a person?
And why can’t it live where I already work — in Slack?
That’s what ThreadQL does.

ThreadQL Lives in Slack
ThreadQL isn’t another app you have to switch to. It’s right in your Slack.

You ask a question in plain English, and ThreadQL responds with your data. No SQL. No context switching. No waiting.

“How many users signed up this month?”
“What’s our top product by revenue?”
“Show me the orders that haven’t shipped yet.”

Ask. Get answers. Done.

Need the Raw Data?
Need further analysis? Just ask ThreadQL to export a CSV and it’ll send it straight in the slack thread. Download, open in Excel and work from there. Your data, delivered where you already are.

Why I’m Open Sourcing It

This isn’t a product I’m selling. It’s a tool I believe belongs to the community.

Organizations deserve to unlock the data they already have. Every person in your company – product, marketing, support, operations – should be able to ask questions and get answers. The knowledge is sitting there in your database. ThreadQL just helps you talk to it.

Under the Hood

For Security:
Only parameterized SELECT queries allowed, SSH Bastion Host support, privacy by design, optional approval-first workflows

For Reliability:
Multi-Tenant Support, LLM Provider Fallback, MySQL & PostgreSQL support, Auto table scanning schedule

For Deployment:
Helm Chart, or docker-compose stack and an admin panel

The Vision

Here’s what I think the future of data access looks like:
You have a question. You ask it in Slack. You get an answer. That’s it.

The database holds your organization’s living knowledge. ThreadQL unlocks it — right where your team already works.

Check it out: https://github.com/emtay-com/threadql

P.S. This is a 0.1.0 release – early but functional. I’ve tested it as thoroughly as I could, but might have missed cases. If you run into issues or have ideas for improvements, your contributions and feedback are more than welcome. And if this clicks, star it on GitHub to help others discover it

When To Use MongoDB’s $elemMatch

You have an e-commerce database. You want to find all orders where a customer bought a laptop for more than $1000. Simple query, right?

db.orders.find({
  "items.category": "laptop",
  "items.price": { $gt: 1000 }
})

Except this returns orders where the customer bought any laptop AND any item over $1000 – even if the laptop itself cost $50.

This is MongoDB’s most common query mistake. Let’s see why it happens and how to fix it.

The Problem: Array Query Logic

Here’s sample data from an e-commerce orders collection:

{
  _id: 1,
  customer: "Alice",
  items: [
    { category: "laptop", price: 1200, name: "MacBook Pro" },
    { category: "mouse", price: 25, name: "Wireless Mouse" }
  ]
}

{
  _id: 2,
  customer: "Bob",
  items: [
    { category: "laptop", price: 500, name: "Chromebook" },
    { category: "monitor", price: 1500, name: "4K Display" }
  ]
}

What you want: Orders where someone bought a laptop costing more than $1000 (Alice’s order only)

What this query returns:

db.orders.find({
  "items.category": "laptop",
  "items.price": { $gt: 1000 }
})

Result: BOTH orders (incorrect)

Why? Because MongoDB checks each condition independently:

  • Does the array contain ANY item with category: "laptop"? → Alice (yes), Bob (yes)
  • Does the array contain ANY item with price > 1000? → Alice (yes), Bob (yes – monitor is $1500)

Bob’s order matches because it has a laptop AND an expensive item – even though they’re different items.

The Fix: $elemMatch

$elemMatch ensures all conditions match the SAME array element:

db.orders.find({
  items: {
    $elemMatch: {
      category: "laptop",
      price: { $gt: 1000 }
    }
  }
})

Result: Only Alice’s order (correct)

Now MongoDB requires a SINGLE item in the array to satisfy both conditions.

The visual query builder makes the structure clear: conditions nested under $elemMatch must all match the same array element.

When You Need $elemMatch

The key question: Do all conditions need to match the SAME array element?

If yes → use $elemMatch. If no → use regular dot notation.

Multiple Conditions on Array of Objects

Imagine you need to find users who have admin access specifically in the finance department. Without $elemMatch, you’ll match users who are admins anywhere AND have any role in finance.

// WRONG - finds any user with admin role OR finance department
db.users.find({
  "roles.role": "admin",
  "roles.department": "finance"
})

This matches Sarah, who is an admin in engineering AND an editor in finance – but she’s not a finance admin. The conditions matched different array elements.

// CORRECT - finds users who are admin IN finance department
db.users.find({
  roles: {
    $elemMatch: {
      role: "admin",
      department: "finance"
    }
  }
})

Now both conditions must match the same role object. Sarah won’t match because no single role has both admin AND finance.

Range Queries on Nested Arrays

Product reviews are another classic case. You want products where Alice specifically gave 5 stars – not products with any Alice review and any 5-star review.

// WRONG - products with any review by Alice OR any 5-star review
db.products.find({
  "reviews.author": "Alice",
  "reviews.rating": 5
})

This returns a product where Alice gave 3 stars but Bob gave 5 stars. The conditions matched different reviews.

// CORRECT - products where Alice gave 5 stars
db.products.find({
  reviews: {
    $elemMatch: {
      author: "Alice",
      rating: 5
    }
  }
})

Now you only get products where Alice’s specific review was 5 stars.

When $elemMatch is NOT Needed

Array of primitives: If the array contains simple values (strings, numbers), you can’t have “multiple conditions on the same element” – there’s only one value per element.

// Array of primitives - $elemMatch not needed
db.products.find({
  tags: "electronics"  // Simple array membership
})

Single condition: If you only have one condition, it doesn’t matter which array element matches.

// Single condition on array - $elemMatch not needed
db.orders.find({
  "items.category": "laptop"  // Just checking if ANY item is a laptop
})

This correctly finds all orders containing at least one laptop, regardless of other items.

Common Mistakes

Mistake 1: Over-Complicating Primitive Array Queries

// ERROR - $elemMatch requires an operator expression, not a direct value
db.products.find({
  tags: { $elemMatch: "electronics" }  // Fails: "electronics" is not an expression
})
// MongoDB expects: { $elemMatch: { <operator>: <value> } }
// Not: { $elemMatch: <value> }

// UNNECESSARILY COMPLEX - this works but is verbose
db.products.find({
  tags: { $elemMatch: { $eq: "electronics" } }  // Valid but overkill
})

// CORRECT - simple and direct
db.products.find({
  tags: "electronics"  // MongoDB automatically checks array membership
})

$elemMatch CAN be used with primitive arrays, but only when you need complex conditions:

// $elemMatch is useful for primitive arrays with multiple conditions
db.sales.find({
  prices: { $elemMatch: { $gt: 100, $lt: 200 } }  // Array element between 100-200
})

// For simple equality, just use direct matching
db.products.find({
  tags: "electronics"  // Much simpler
})

Mistake 2: Mixing $elemMatch with Top-Level Conditions

// WRONG - status is outside $elemMatch
db.orders.find({
  items: {
    $elemMatch: {
      category: "laptop",
      price: { $gt: 1000 }
    }
  },
  "items.status": "shipped"  // Checks ANY item's status (incorrect)
})

// CORRECT - all array conditions inside $elemMatch
db.orders.find({
  items: {
    $elemMatch: {
      category: "laptop",
      price: { $gt: 1000 },
      status: "shipped"  // Same item must be shipped (correct)
    }
  }
})

Mistake 3: Over-Using $elemMatch

// UNNECESSARY - single condition doesn't need $elemMatch
db.orders.find({
  items: {
    $elemMatch: {
      category: "laptop"
    }
  }
})

// SIMPLER
db.orders.find({
  "items.category": "laptop"
})

Quick Reference

Scenario Use $elemMatch? Example
Array of primitives, single condition No tags: "mongodb"
Array of objects, single condition No "items.category": "laptop"
Array of objects, multiple conditions, same element Yes items: { $elemMatch: { category: "laptop", price: { $gt: 1000 } } }
Array of objects, multiple conditions, any element No "items.category": "laptop", "items.inStock": true

The Takeaway

$elemMatch is essential when you need all conditions to match the SAME array element. Without it, MongoDB checks conditions independently, which can return unexpected results.

Rules of thumb:

  • Array of primitives → no $elemMatch needed
  • Array of objects, single condition → no $elemMatch needed
  • Array of objects, multiple conditions on same element → use $elemMatch

VisuaLeaf is a MongoDB GUI built for developers. The visual query builder makes complex queries like $elemMatch easier to understand and build correctly.

Try it at visualeaf.com

Examples tested with MongoDB 8.0. Demo data generator script available in VisuaLeaf documentation.

Sources and References

This article is based on MongoDB’s official documentation and query behavior:

  1. MongoDB $elemMatch Projection Operator
    MongoDB Documentation
    https://www.mongodb.com/docs/manual/reference/operator/projection/elemMatch/

  2. MongoDB $elemMatch Query Operator
    MongoDB Documentation
    https://www.mongodb.com/docs/manual/reference/operator/query/elemMatch/

  3. Query an Array of Embedded Documents
    MongoDB Documentation
    https://www.mongodb.com/docs/manual/tutorial/query-array-of-documents/

  4. MongoDB Query Operators
    MongoDB Documentation
    https://www.mongodb.com/docs/manual/reference/operator/query/

All query examples and behaviors described in this article are based on MongoDB 8.0 specifications and have been verified against the official MongoDB documentation.

Written by the VisuaLeaf team • Questions? [email protected]

Alter Tables

1. Make email NOT NULL in customers

ALTER TABLE customers
ALTER COLUMN email SET NOT NULL;

Ensures all future records must have an email.

2. Make username unique in users

ALTER TABLE users ADD CONSTRAINT unique_username UNIQUE (username);

Prevents duplicate usernames.

3. Add CHECK on price in products

ALTER TABLE products ADD CONSTRAINT check_price CHECK (price > 0);

Ensures price is always greater than 0.

4. Set default status in orders

ALTER TABLE orders
ALTER COLUMN status SET DEFAULT ‘pending’;

Automatically assigns pending if no value is given.

5. Add salary column with constraints

ALTER TABLE employees ADD COLUMN salary INT NOT NULL CHECK (salary > 10000);

Ensures salary is mandatory and above 10,000.

6. Add ON DELETE CASCADE to foreign key

ALTER TABLE employees DROP CONSTRAINT employees_department_id_fkey;

ALTER TABLE employees ADD CONSTRAINT employees_department_id_fkey FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE;

Deletes employees automatically when a department is removed.

7. Remove CHECK constraint on balance

ALTER TABLE accounts DROP CONSTRAINT accounts_balance_check;

Removes restriction on balance.

8. Add composite UNIQUE in payments

ALTER TABLE payments ADD CONSTRAINT unique_user_transaction UNIQUE (user_id, transaction_id);

Ensures no duplicate transaction per user.

I Built a Full SaaS Finance App Solo Using AI — Here’s My Honest Take on Every Model I Used

I’m a DevOps engineer. Not a “real” fullstack developer by training. But over the past year I’ve been building YourFinanceWORKS — a self-hosted, AI-powered finance platform with invoicing, OCR receipt processing, bank reconciliation, multi-tenant architecture, MCP integration, and a full business intelligence layer.

Python/FastAPI backend. React + TypeScript + Vite frontend. 670+ commits. One person.

AI made this possible. And I went through a lot of models to get here.

This is my honest, unsponsored breakdown of every model I used — what worked, what didn’t, and what surprised me.

🧪 Phase 1: The Free Model Rotation

Before paying for anything, I treated free models like a buffet. Here’s what I found:

Windsurf SWE-1.5

Genuinely the best free option for coding tasks. Fast responses, decent output quality, and it handles real engineering problems — not just toy examples. I’ve half-jokingly wondered if Windsurf has some Google collaboration going on given how polished it feels for a free tier.

If you’re a developer on a budget, start here.

Grok Code

Worth keeping in your rotation. Solid for dev tasks and it’s free. Not my first call for complex refactoring, but for quick code generation and boilerplate it holds its own.

Ollama (Local Models)

I really wanted this to work. The appeal of running everything locally — privacy, no API costs, full control — is real. And the output quality was okay. But the speed killed my flow completely. When you’re deep in debugging a multi-tenant isolation issue, waiting 30+ seconds per response breaks the mental thread entirely. Local models will get there, but they’re not there yet for serious daily use on consumer hardware.

Free OCR Models

This one matters a lot for YourFinanceWORKS since OCR is central to the receipt and invoice processing features. My honest experience: roughly 80% accuracy. That sounds acceptable until you’re reconciling actual financial data and realize that 20% error rate means manual correction on 1 in 5 documents. Not viable at scale.

For comparison, Claude Haiku — Anthropic’s smallest paid model — made noticeably fewer mistakes. Not zero, but the gap was meaningful.

Gemini Free Tier + Mistral Le Chat

Both solid for research, long-context reading, and general questions. Mistral is underrated for reasoning tasks. Neither replaced a proper coding assistant for me, but they filled gaps well.

📅 Phase 2: Gemini Advanced (A Couple of Months)

I paid for Gemini Advanced for a couple of months and it had genuine strengths.

Where it excelled: UI work. Gemini has a strong instinct for layout and component design. For the React/TypeScript frontend — building out dashboards, data tables, form flows — it consistently produced clean, well-structured components. If your work is frontend-heavy, Gemini is worth serious consideration.

Where it fell short (for me): Deep backend logic. When I was debugging gnarly FastAPI issues, refactoring complex multi-tenant database isolation, or reasoning through Kafka event flow — the responses felt like they understood the surface of the problem but not the intent behind it. I kept having to over-explain context that a more senior-feeling model would have inferred.

Good product. Just not the right fit for the kind of work I was doing most.

✅ Phase 3: Claude Pro (This Month — Where I Landed)

This is where bug fixing and refactoring finally felt right.

The difference I kept noticing: Claude understands the intent behind code, not just the syntax. When I’m tracking down a subtle multi-tenant isolation bug, it doesn’t just look at the function I paste — it reasons about what I’m trying to do and where the breakdown is likely happening. When I ask for a refactor, it asks clarifying questions that show it understood the architecture, not just the snippet.

For a project like YourFinanceWORKS — with real financial logic, security-sensitive multi-tenant patterns, and a lot of interconnected moving parts — that difference matters a lot.

Is it perfect? No. It makes mistakes. It sometimes over-explains. But for the specific task of “help me fix this real bug in a real codebase,” it’s the best I’ve used.

🤔 The Rate Limit: An Unpopular Opinion

Here’s something I didn’t expect to say: Claude’s rate limit has been good for me.

I was using AI as a pure reflex. Every half-formed thought, every minor question, every moment of uncertainty — I’d just fire it off immediately without thinking first. It became a dependency loop. The question would pop into my head and I’d be typing before I even finished forming the thought.

The rate limit broke that loop.

Suddenly I had to triage. Is this question actually worth using a message on? Can I think through this myself first? Can I look it up? The friction forced me back into the habit of actually thinking — and I started retaining more because I wasn’t just offloading everything.

Long term, I genuinely believe the rate limit is helping me avoid a real AI dependency problem. I came to Claude a bit addicted to the instant-answer reflex that unlimited free models had built in me. The constraint is doing what I couldn’t do on my own: making me more intentional.

I know that’s not a popular take. But it’s been my experience.

🗺️ My Model-to-Task Map (After All This)

Task My Go-To
Bug fixing & refactoring Claude (not close)
UI / frontend components Gemini
Quick code generation Windsurf SWE-1.5 or Grok Code
Local / private work Ollama (if speed isn’t critical)
OCR at scale Don’t cheap out — the accuracy gap is real
Research & long-context Gemini or Mistral

💭 The Bigger Question

The README for YourFinanceWORKS ends with a question I’ve been sitting with:

Is the SaaS business model really dying? With AI drastically lowering the barrier to building sophisticated software, what can one person with DevOps skills and AI assistance actually ship?

I don’t have a clean answer yet. But I built a multi-tenant finance SaaS with OCR, banking reconciliation, MCP integration, and business intelligence — solo, while working a day job — and it runs in production.

That used to require a team. Now it requires the right AI stack and the discipline to use it well.

The project is open source (AGPL-3.0 for the core): github.com/snowsky/yourfinanceworks. Happy to discuss the architecture, the AI workflow, or anything else in the comments.

JavaScript DOM Explained: Concepts, Examples, and Interview Questions

🌐 Complete Guide to DOM in JavaScript (Beginner to Advanced)

📌 Introduction

In modern web development, creating interactive and dynamic web pages is essential. This is where the Document Object Model (DOM) plays a crucial role. The DOM acts as a bridge between HTML and JavaScript, allowing developers to control and manipulate web pages programmatically.

This blog provides a complete understanding of the DOM, starting from basic definitions to advanced concepts, along with interview questions and answers.

🧠 What is DOM?

📖 Definition

The Document Object Model (DOM) is a programming interface for web documents. It represents the structure of an HTML or XML document as a tree of objects, where each object corresponds to a part of the document.

JavaScript uses the DOM to:

  • Access HTML elements
  • Modify content
  • Change styles
  • Handle events
  • Create dynamic web pages

💡 Simple Explanation

Think of a webpage as a tree structure:

  • The entire webpage = Root node (document)
  • HTML tags = Elements (nodes)
  • Content inside elements = Text nodes

This structure allows JavaScript to easily navigate and update the page.

🌳 Example of DOM Structure

<!DOCTYPE html>
<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <h1>Hello World</h1>
    <p>This is a paragraph</p>
  </body>
</html>

DOM Tree Representation:

Document
 └── html
     ├── head
     │   └── title
     └── body
         ├── h1
         └── p

⚙️ DOM Manipulation in JavaScript

🔍 Selecting Elements

JavaScript provides multiple ways to select elements:

document.getElementById("id")
document.getElementsByClassName("class")
document.getElementsByTagName("tag")
document.querySelector("selector")
document.querySelectorAll("selector")

Example:

let heading = document.getElementById("title");

✏️ Changing Content

document.getElementById("demo").innerHTML = "Hello JavaScript";

Explanation:

  • innerHTML allows inserting HTML content
  • It replaces existing content

🎨 Changing Styles

document.getElementById("demo").style.color = "blue";
document.getElementById("demo").style.fontSize = "20px";

➕ Creating Elements

let newPara = document.createElement("p");
newPara.textContent = "This is a new paragraph";
document.body.appendChild(newPara);

❌ Removing Elements

let element = document.getElementById("demo");
element.remove();

🔄 Replacing Elements

let newElement = document.createElement("h2");
newElement.textContent = "New Heading";

let oldElement = document.getElementById("old");
oldElement.replaceWith(newElement);

🎯 Event Handling in DOM

Events allow interaction between user and webpage.

📌 Common Events

  • click
  • mouseover
  • mouseout
  • keydown
  • submit

🧪 Example

document.getElementById("btn").addEventListener("click", function() {
  alert("Button Clicked!");
});

🔥 Event Types

1. Event Bubbling

Event starts from child and moves upward.

2. Event Capturing

Event starts from parent and moves downward.

⚡ Event Delegation

Instead of adding multiple event listeners, we add one listener to the parent.

document.getElementById("parent").addEventListener("click", function(e) {
  console.log(e.target);
});

🧱 Types of DOM Nodes

  1. Element Node → <div>, <p>
  2. Text Node → Text inside elements
  3. Attribute Node → id, class
  4. Comment Node → Comments in HTML

🔑 Important DOM Properties

  • innerHTML
  • textContent
  • innerText
  • style
  • value
  • parentNode
  • childNodes
  • firstChild
  • lastChild

🔍 DOM Navigation

let parent = element.parentNode;
let children = element.childNodes;
let first = element.firstChild;
let last = element.lastChild;

⚖️ innerHTML vs textContent vs innerText

Property Description
innerHTML Returns HTML content
textContent Returns all text
innerText Returns visible text

⚡ DOM vs BOM

DOM BOM
Deals with HTML Deals with browser
document object window object
Page content Browser features

🚀 Advantages of DOM

  • Makes web pages dynamic
  • Easy to manipulate HTML
  • Supports event handling
  • Platform independent
  • Works with all browsers

⚠️ Disadvantages of DOM

  • Performance issues for large pages
  • Complex for beginners
  • Memory usage can increase

❓ Interview Questions and Answers

1. What is DOM?

DOM is a tree-like representation of HTML that allows JavaScript to manipulate web pages.

2. What is the difference between innerHTML and textContent?

  • innerHTML → includes HTML tags
  • textContent → only text

3. What is querySelector?

It selects the first matching element using CSS selectors.

4. What is addEventListener?

It attaches an event handler to an element without overwriting existing handlers.

5. What is Event Bubbling?

Event propagates from child to parent.

6. What is Event Delegation?

Using a parent element to handle events for child elements.

7. Difference between getElementById and querySelector

  • getElementById → fast, only ID
  • querySelector → flexible, supports CSS

8. What is createElement?

Used to create new HTML elements dynamically.

9. What is remove()?

Removes an element from the DOM.

10. What is DOM Manipulation?

Changing structure, content, or style using JavaScript.

🧪 Real-World Example

<button id="btn">Click Me</button>
<p id="text">Hello</p>

<script>
document.getElementById("btn").addEventListener("click", function() {
  document.getElementById("text").innerHTML = "Changed!";
});
</script>

📚 Best Practices

  • Use querySelector for flexibility
  • Avoid excessive DOM manipulation
  • Use event delegation for performance
  • Keep code clean and readable

🏁 Conclusion

The DOM is one of the most important concepts in JavaScript. It allows developers to create dynamic, interactive, and user-friendly web applications. Understanding DOM deeply will help you become a strong front-end developer.

Mastering DOM means you can:

  • Build interactive websites
  • Handle user input efficiently
  • Create modern web applications

✨ Final Tip: Practice DOM manipulation daily with small projects like:

  • To-do list
  • Calculator
  • Form validation

This will strengthen your understanding and confidence.

SurrealMX: In-memory storage with time travel and persistent storage

Author: Dave MacLeod

SurrealMX: SurrealDB’s new in-memory storage backend

There have been quite a few blog posts since the release of SurrealDB 3.0 to introduce each of the new features we have to show. Today’s post is about one called SurrealMX that you likely have yet to hear about – but use every day.

That’s because the way you use it is…by typing surreal start. That’s it!

That’s because SurrealMX is in fact the name of SurrealDB’s new in-memory datastore. And since SurrealDB stores data in memory by default, 100% of SurrealDB’s users have already given it a try.

What is SurrealMX?

SurrealMX is SurrealDB’s in-memory storage engine that was built from the ground up a bit over a year ago. It was merged into the core database during the 3.0 alpha period last November with no fanfare at all in order to try it out for the first time as SurrealDB’s in-memory storage engine. Since SurrealDB uses in-memory storage by default, that means that SurrealMX has been every 3.x user’s default storage option since then.

But SurrealMX isn’t just a more performant way to store data in memory, it comes with new optional functionality too.

If you pay close attention to the SurrealDB source code you might have noticed a PR merged three weeks ago in the runup to 3.0 general availability that included a new way to pass in configuration parameters when starting a database.

If you go to this part of the documentation for the surreal start command, you can see a comparison of how this worked in 2.x versions compared to 3.0.

In 2.x versions, specifying whether SurrealKV is versioned or not was done before the path to the database which led to either writing surrealkv or surrealkv+versioned. Other backends didn’t have similar configurations.

surrealkv://my_db
rocksdb://my_db
surrealkv://mydb
surrealkv+versioned://mydb
memory

In 3.0, this has now been changed to pass as many configurations as needed after the path to the database, in the same way that you do the same with a URL. Here are two examples from the documentation showing how this is done.

"surrealkv://path/to/db?versioned=true&sync=every&retention=30d"
"mem://tmp/data?versioned=true&aol=sync&snapshot=60s&sync=5s"
"mem://?versioned=true"

Did you notice that versioned=true is found not just in the surrealkv but also the mem backend? That’s because SurrealMX includes versioned queries in the same way that SurrealKV does.

Let’s take a closer look!

Time travel using the VERSION clause

With the addition of SurrealMX, SurrealDB now has two storage backgrounds that have versioning: SurrealKV and memory. Versioning is the capability to not just query a table as it stands now, but as it stood before. This feature is exceptionally important in areas like legal compliance in which a database needs to keep past information intact even if it has been overwritten later on.

In SurrealDB, time travel queries are done using the VERSION keyword. Let’s give this a quick try with the in-memory backend. First we will start a server with ?versioned=true added:

surreal start --user root --pass secret "mem://?versioned=true"

And then connect via Surrealist or this command in the CLI.

surreal sql --user root --pass secret

Once that is done, we can create a person record or two and make some queries using VERSION followed by time::now() minus how ever long ago we would like to query.

CREATE person;
[{ id: person:g4z4v7yoju9pe21jck6v }]

SELECT * FROM person;
[{ id: person:g4z4v7yoju9pe21jck6v }]

// No 'person' records existed yesterday
SELECT * FROM person VERSION time::now() - 1d
[]

// Add a second record
CREATE person;
[[{ id: person:aczudhy0mvef5bxurcry }]]

// 'person' has two records, but about ten seconds ago
// it had just one
SELECT * FROM person VERSION time::now() - 10s
[{ id: person:g4z4v7yoju9pe21jck6v }]

Since the VERSION keyword has been available via SurrealKV since SurrealDB 2.0, it’s very likely that you are already quite familiar with it. Let’s now move on to in-memory persistent storage options which were entirely unavailable before SurrealDB 3.0.

In-memory with optional persistent storage and versioning

In-memory storage with optional persistent storage is one of the features of Redis that has made it so popular. SurrealMX offers this as well, via a somewhat more composable model.

Persistence for the memory backend is used for the following:

  • Append-Only Log (AOL) – Synchronous/asynchronous modes for durability
  • Snapshots – Periodic full database state capture
  • Data Recovery – Automatic recovery from snapshots + AOL on startup
  • AOL Truncation – Automatic cleanup after snapshots

You may be curious at this point how this differs from using SurrealKV (or RocksDB) to store your data. The difference is that SurrealMX persistent storage does not compress the data. It is an append-only-log of database writes, never compacted, until a snapshot occurs. It is in effect an entire database export. You can think of it as a convenience on top of in-memory speed while SurrealKV is used for traditional durable storage for datasets of any size.

Trying it out

Let’s grab part of the documentation to see how it works. These are the available parameters to use when starting a database with in-memory storage.

  • retention (a duration string, e.g. 30d, 24h, 30m)
  • aol (never, sync, or async), for writing changes to an append-only log file
  • snapshot (a duration string, e.g. 60s), for periodically writing a snapshot of the database to the file system
  • sync (never, every, or a duration string like 5s), for specifying when to flush the append-only log file to the file system
  • never – (default) leave flushing to the OS (least durable)
  • every – sync on every commit (most durable)
  • interval – periodic background flushing at the given interval

We can match this up against the example above which shows that aol is set to sync, snapshot is set to 60s, and sync is set to 5s.

"mem://tmp/data?versioned=true&aol=sync&snapshot=60s&sync=5s"

That’s quite a bit to think about, but fortunately the SurrealMX repo has a table that lays out exactly when you would want to use one configuration over another. Each of these is balanced against the performance hit, ranging from no persistence at all (the fastest) to Sync AOL + Every fsync which survives both process and system crash at the greatest cost to performance.

To make a choice, just ask yourself the following as you move down the chart: what do you want to happen to your data if the process or system crashes? Then choose the most performant option that offers you the guarantees you need.

Configuration Survives Process Crash Survives System Crash Performance
No persistence ❌ ❌ Fastest
Snapshot-only ⚠️ (last snapshot) ⚠️ (last snapshot) Fastest
Async AOL + No fsync ⚠️ (mostly) ⚠️ (mostly + OS buffers) Very fast
Async AOL + Interval fsync ⚠️ (mostly) ⚠️ (mostly + since last fsync) Very fast
Async AOL + Every fsync ⚠️ (mostly) ⚠️ (mostly) Very fast
Sync AOL + No fsync ✅ ⚠️ (OS buffers) Fast
Sync AOL + Interval fsync ✅ ⚠️ (since last fsync) Fast
Sync AOL + Every fsync ✅ ✅ Slow

Looks like the quickest and easiest way to use persistent storage is through periodic snapshots. Since these must be set to a number at least greater than 30 seconds, we’ll give it a try with 31.

surreal start --user root --pass secret "mem://tmp/data?snapshot=31s"

We can then connect with Surrealist or the surreal sql --user root --pass secret command and do a CREATE person or two.

After that, use cd tmp/data to go into the folder and wait for the file to show up. After 30 seconds you should see a file called snapshot.bin. That’s the snapshot!

!v�4��y��
         /!cgdefault�?H/�y��mainmain/!ndI��� H�
��t����4P���I��� H�
��t��@~����D�&���I��� H�
...snip
*/***person*rh6xmo5z8cqffvp4hbf6��BQ7
                                     ��
/***user*john��T|��
nameJohn%

Once that file is present, you can stop and restart the database, use SELECT * FROM person and see that the person records are still there. Not bad at all!

We suspect that the most popular options will be the following four:

  • Snapshot only: the fastest option
  • Async AOL + no fsync: the fastest option that tracks every change the moment one happens
  • Sync AOL + no fsync: the fastest option if you think you might have a process crash
  • Sync AOL + every fsync: the most durable option, survives both process and systems crashes

But that remains to be seen.

If you’ve given persistent in-memory storage a try already or have used it since the publication of this blog post, please get in touch and let us know!

Your AI Coding Tool Has Amnesia

I watched one of our engineers explain the same authentication pattern to Claude Code for the fourth time last month. Not because he forgot he’d explained it. Because the tool forgot. Every session, from scratch. “We use JWT validation at the gateway layer, not in individual services.” He’d said it three days ago. And the … continue reading

The post Your AI Coding Tool Has Amnesia appeared first on SD Times.

Why Your Video Player Is Bloating Your Bundle (and How to Fix It)

If you’ve ever cracked open your webpack bundle analyzer and seen a giant chunk labeled video.js, you know the feeling. That sinking realization that your video player alone is heavier than the rest of your app combined.

I hit this exact wall two months ago. A client’s Lighthouse score was tanking on mobile, and after some digging, the culprit was obvious: video.js v8 was shipping nearly 600KB of minified JavaScript. For a page that sometimes didn’t even autoplay video until the user scrolled down. Brutal.

The good news? Video.js just dropped a v10 beta that’s 88% smaller than its predecessor. Let’s dig into why the old version was so bloated, how they fixed it, and how you can migrate without blowing up your existing player setup.

The Root Cause: Legacy Architecture Debt

Video.js has been around since 2010. That’s ancient in JavaScript years. Over time, it accumulated a lot of weight:

  • A custom UI component system that predates modern frameworks
  • A full CSS stylesheet bundled inline
  • Flash fallback code (yes, really — remnants hung around for ages)
  • A custom DOM manipulation layer that duplicated what browsers already do well
  • Middleware and plugin systems with layers of abstraction

The core problem wasn’t any single feature. It was that Video.js was essentially a framework-within-a-framework. It had its own class system, its own event system, its own DOM helpers — all things that made sense in 2010 but are dead weight in 2026.

Here’s what a basic v8 setup looks like for reference:

// v8 — the "classic" way
import videojs from 'video.js';
import 'video.js/dist/video-js.css'; // ~30KB of CSS you may not need

const player = videojs('my-video', {
  controls: true,
  autoplay: false,
  preload: 'auto',
  fluid: true
});

// Even this minimal setup pulls in ~580KB minified (before gzip)

That import drags in the entire kitchen sink. Every UI component, every legacy polyfill, every feature you’re not using. There was no tree-shaking because the architecture wasn’t designed for it.

The Fix: Video.js v10’s Ground-Up Rewrite

The v10 beta takes a fundamentally different approach. Instead of shipping a monolith, it’s been rewritten as a lean core with opt-in features. The headline stat is real: the core player dropped from roughly 580KB to around 70KB minified.

Here’s what changed architecturally:

  • Native web APIs instead of custom abstractions — No more custom DOM layer. They use standard HTMLMediaElement APIs directly.
  • Tree-shakeable ESM modules — Features you don’t import don’t ship. Period.
  • CSS decoupled from JS — Styles are separate and optional. Bring your own design system if you want.
  • Modern baseline — No more IE11 polyfills. The target is modern evergreen browsers.

Step 1: Install the Beta

npm install video.js@next
# or if you want to pin the exact beta
npm install [email protected]

Fair warning: this is a beta. Don’t deploy it to production on a site doing 10 million video plays a day. But for new projects or internal tools? Absolutely worth trying.

Step 2: Update Your Player Initialization

The new API is simpler and more explicit:

// v10 — lean and explicit
import { Player } from 'video.js';

const player = new Player(document.querySelector('#my-video'), {
  controls: true,
  autoplay: false,
  preload: 'auto'
});

// Core is ~70KB minified. That's it. No CSS forced on you.

Notice the shift: you’re importing Player as a named export, not a default function. This is what makes tree-shaking work. The bundler can see exactly what you’re using.

Step 3: Add Only What You Need

Need HLS streaming? Import it explicitly:

import { Player } from 'video.js';
import { HlsHandler } from 'video.js/modules/hls';

const player = new Player(document.querySelector('#my-video'), {
  controls: true,
  techOrder: [HlsHandler]
});

// Only pays the cost for what you actually use

This modular approach means your bundle only includes the code paths you actually exercise. If you’re just playing a simple MP4 with basic controls, you get basic MP4 with basic controls — not an HLS stack, not ad integration hooks, not a quality selector you’ll never render.

Step 4: Handle the CSS Situation

In v8, the player looked decent out of the box because it force-bundled a stylesheet. In v10, you have options:

/* Option A: Import their minimal base styles */
@import 'video.js/styles/base.css';

/* Option B: Just target the native elements yourself */
video {
  width: 100%;
  aspect-ratio: 16 / 9;
}

.vjs-controls {
  /* style controls however you want */
}

I actually prefer this approach. In v8, I spent more time overriding their CSS than I’d like to admit. Now the styling layer is genuinely optional.

Checking Your Bundle Impact

After migrating, verify the size difference. Here’s a quick way to measure:

# Check what video.js contributes to your bundle
npx esbuild your-entry.js --bundle --minify --analyze | grep video

# Or if you're on webpack, the classic:
npx webpack-bundle-analyzer dist/stats.json

On the project I migrated, the video-related JavaScript went from 187KB gzipped to about 22KB gzipped. That’s not a typo. My Lighthouse performance score jumped 12 points on mobile just from this change.

What Breaks (Honestly)

Let’s be real about the tradeoffs:

  • Plugins will break. If you rely on v8 plugins from the ecosystem, they won’t work with v10 out of the box. The plugin API has changed significantly.
  • The component system is different. If you built custom UI components using videojs.registerComponent(), you’ll need to rewrite them.
  • Some events have changed. The core events (play, pause, ended) are the same, but higher-level custom events may have shifted.
  • No more videojs() function. It’s a class now. new Player() is the way.

If you’ve got a complex player with custom skins, multiple plugins, and ad integration, this migration is non-trivial. You’ll want to wait for the stable release and for plugin authors to catch up.

Prevention: Stop Shipping What You Don’t Use

This situation with Video.js is a pattern I see everywhere. Libraries start small, accumulate features for a decade, and suddenly you’re shipping half a megabyte of code to play a 30-second clip.

Some rules I follow now:

  • Audit your bundle quarterly. Run npx bundlephobia <package> before adding any new dependency. Set a size budget and stick to it.
  • Prefer libraries with ESM exports. If a library only ships CommonJS, tree-shaking is either impossible or unreliable.
  • Lazy-load heavy media components. Your video player doesn’t need to be in the initial bundle if the video is below the fold.
  • Consider native <video> first. If you just need play/pause on an MP4, you might not need a library at all. The browser’s native player has gotten remarkably good.
// Sometimes this is all you need. Seriously.
const video = document.querySelector('video');
video.addEventListener('ended', () => showNextContent());
// The browser handles controls, fullscreen, PiP — all of it

The Video.js team deserves credit here. Rewriting a library used by millions of sites is gutsy. Most maintainers would keep bolting features onto the existing architecture. Throwing it out and starting fresh, with a clear focus on bundle size, is the right call.

I’ll be watching the v10 beta closely. If you’re starting a new project with video, it’s worth trying now. If you’re maintaining an existing v8 setup, start planning the migration but don’t rush it — wait for the plugin ecosystem to catch up.

Either way, go check your bundle. You might be surprised what’s hiding in there.