RDS Reserved Instance Pricing: Every Engine, Every Rule, Real Dollar Savings

Every RDS instance running on-demand is paying the highest rate AWS offers. RDS reserved instance pricing cuts that rate by 29% on the lowest 1-year commitment to 69% on a 3-year All Upfront term, depending on engine and instance type. For a db.r8g.xlarge PostgreSQL Multi-AZ, the difference between on-demand and 3-year reserved is roughly $4,800/year on a single instance. Across a fleet of 20, that’s $96,000/year in avoidable spend.

The mechanics are more nuanced than EC2. The engine matters as much as the instance type. Size flexibility rules differ by engine. Oracle and SQL Server license model variants produce dramatically different reservation economics. Extended Support charges a significant cost item since March 2026 are specifically excluded from RI discounts, creating a trap for teams that reserve first and then discover their engine version is past end-of-standard-support.

What Are RDS Reserved Instances?

RDS reserved instances are a billing commitment: discounted hourly rate in exchange for a 1-year or 3-year term. You specify the engine, instance class, region, deployment type (Multi-AZ or Single-AZ), and payment option. AWS automatically applies the discount to matching running instances.

You are billed for the entire term regardless of whether the instance is running. Stop an RDS instance and you still pay the reserved rate. Terminate it; the reservation keeps billing until it expires. There are no Convertible RDS reserved instances (unlike EC2), so you cannot change engines or families after purchasing.

Payment Options

Three structures, with one constraint most guides skip: No Upfront is only available on 1-year terms. For 3-year, you must choose Partial or All Upfront.

No Upfront (1-year only): No capital outlay. Pay the reserved hourly rate for every hour of the term. Saves ~29–34% vs on-demand. Cost-positive from hour one.

Partial Upfront (1-year or 3-year): Pay a portion upfront, reduced hourly rate for the remainder. ~33–38% savings on 1-year, ~50–60% on 3-year. Most commonly chosen for 3-year terms.

All Upfront (1-year or 3-year): Full payment at purchase, zero hourly charges. Highest savings: ~34–38% for 1-year, up to 63–69% for 3-year. The difference between All Upfront and No Upfront on the same term is typically 3–10%, which becomes significant on large instance types. For a db.r8g.xlarge Oracle BYOL over 3 years, that spread is approximately $1,500–2,000 total.

Which Engines Support Size Flexibility?

Size flexibility lets your RI discount apply proportionally across instance sizes within the same family, using normalization units.

Engines with size flexibility: MySQL, MariaDB, PostgreSQL, Aurora (MySQL and PostgreSQL), Oracle BYOL.

Engines WITHOUT size flexibility: Microsoft SQL Server (all editions), Oracle License Included.

Normalization units follow the vCPU doubling pattern: micro = 0.5, small = 1, medium = 2, large = 4, xlarge = 8, 2xlarge = 16, 4xlarge = 32, 8xlarge = 64.

A db.r8g.xlarge reservation (8 units) covers two db.r8g.large instances (4 units each). A db.r8g.4xlarge reservation (32 units) fully covers four db.r8g.large instances (16 units) with 16 units remaining.

For SQL Server and Oracle LI, where size flexibility does not apply, you must match your exact instance size at purchase time. Resizing a SQL Server instance voids reservation coverage making right-sizing decisions significantly riskier before committing.

If you want to understand how AWS Savings Plans share across consolidated billing accounts, we covered it in detail here How AWS Savings Plans Share Across Consolidated Billing Accounts

Real Dollar Savings by Scenario

Percentages are less useful than dollar amounts when justifying a reservation to finance. Four realistic production scenarios:

PostgreSQL db.r8g.xlarge Multi-AZ On-demand: $0.960/hr × 8,760 hrs = $8,410/year. 1-year No Upfront: ~$5,974/year, saving $2,436 (29%). 3-year All Upfront amortized: ~$4,117/year, saving $4,293 (51%). Over 3 years on 5 such instances: $64,395 total savings.

MySQL cluster (4× db.r8g.large Single-AZ) On-demand: $8,410/year for all 4. 3-year All Upfront: ~50% savings = ~$4,205/year. 3-year total savings: ~$12,615.

SQL Server Standard (db.r8g.xlarge Multi-AZ, 2 instances) On-demand: ~$42,889/year. 1-year No Upfront: ~25% savings = $10,722/year. 3-year All Upfront: ~41% savings = $17,585/year. 3-year total savings: $52,755. High absolute rates mean even a 25% RI discount generates large absolute dollar savings.

Oracle BYOL vs License Included Oracle LI db.r8g.xlarge Multi-AZ on-demand: $3.124/hr. Oracle BYOL same spec on-demand: $0.960/hr. Migrating from LI to BYOL before reserving saves $18,957/year before any RI discount. After migration to BYOL with 3-year All Upfront: ~$0.470/hr effective rate. Total saving vs Oracle LI on-demand: $23,249/year $69,747 over 3 years. If you own Oracle licenses through an Enterprise Agreement, BYOL plus 3-year reservation is the single highest-impact RI decision in the RDS portfolio.

The Extended Support Cost Trap

Starting March 1, 2026, AWS charges $0.20 per vCPU-hour in US East for RDS Extended Support on MySQL and PostgreSQL instances past their major version end-of-standard-support date.

The critical rule: RDS reserved instance discounts do NOT apply to Extended Support charges. The surcharge is calculated separately on top of your instance rate.

Worked example db.m7g.xlarge (4 vCPUs) running MySQL 8.0 after it reaches EOL:

  • Base compute with 3-year All Upfront reserved: ~$0.140/hr
  • Extended Support surcharge: 4 vCPUs × $0.20/hr = $0.80/hr
  • Effective hourly rate: $0.940/hr
  • Compare to original on-demand without Extended Support: $0.311/hr

A team on a 3-year reserved instance ends up paying 3× the original on-demand rate purely because of the surcharge on an EOL engine.

Engine versions approaching end-of-standard-support: MySQL 8.0 (community EOL April 2026), PostgreSQL 14 (November 2026). Always verify current EOL dates before purchasing multi-year reservations on any engine version.

The right sequence: upgrade your engine version first. Then purchase reserved instances.

For spend-based flexibility as an alternative to per-instance commitments, see how Database Savings Plans compare Understanding Savings Plan Amortized Cost in AWS Cost Explorer

How to Buy RDS Reserved Instances Correctly

  1. Identify stable, long-running instances. Production databases running 24/7, stable in size for 30+ days, expected to continue in the same region for 12+ months. Instances that stop regularly (dev environments, batch jobs) are poor candidates, you pay for every hour of the term.

  2. Verify engine version before committing. Confirm the version has at least 18 months of standard support remaining within your reservation term. If not, the Extended Support surcharge can eliminate RI savings entirely.

  3. Migrate to Graviton before reserving. Graviton3 instances (db.m7g, db.r7g, db.t4g) deliver ~15% better price-performance than equivalent x86 at the same or lower on-demand rate. Migrating from db.m5/db.r5 before reserving reduces the base rate you’re committing against, while still delivering the same 29–69% RI discount.

  4. Start with 1-year No Upfront. Saves 29–34% with no capital outlay. After 12 months of confirmed stable usage, evaluate the 3-year term. The incremental savings from extending to 3-year All Upfront; an additional 15–35 percentage points depending on the engine, justify careful analysis before each renewal.

  5. Use size flexibility to right-size without risk. For MySQL, PostgreSQL, MariaDB, Aurora, and Oracle BYOL, buy reservations at the instance family level. A db.r8g family reservation automatically covers size changes up or down within that family, removing the capacity planning rigidity that makes SQL Server and Oracle LI reservations significantly more risky.

Automating RDS RI Management with Usage.ai

Managing RDS reserved instances across a fleet of 20–50 instances requires constant tracking: utilization checks, term expiration alerts, new instance qualification, and engine EOL monitoring.

Usage.ai Flex Reserved Instances refresh RDS utilization analysis every 24 hours, versus AWS Cost Explorer’s 72+ hour cycle. At $6–12K/day in uncovered RDS on-demand spend for a mid-size fleet, a 3-day lag means $18–36K in unnecessary charges per review cycle. For Oracle and SQL Server where Database Savings Plans don’t apply, Usage.ai Flex Reserved Instances is the only automated optimization path. If a reservation becomes underutilized because an instance is resized or deprecated, Usage.ai provides cashback and credits on the unused portion. The fee is a percentage of realized savings only.

What’s been the trickiest part of managing RDS reserved instances at your org sizing SQL Server reservations, catching EOL engine versions before committing, or something else?

For the complete technical breakdown, read the full article here → RDS Reserved Instances: Engine-by-Engine Pricing and Commitment Guide

How To Build An AI-Powered MVP Without Burning Your Startup Budget In 2026

Want to build an AI-powered MVP without burning your startup budget in 2026?

Start by refusing to build everything.

Most founders don’t lose money because AI is expensive. They lose money because the MVP is too wide, the data plan is weak, and the first release tries to impress everyone. An MVP should prove one sharp use case, with one user type, and one measurable outcome.

That’s it.

This guide gives you a practical, founder-friendly checklist to build smarter, spend cleaner, and avoid the classic $50K mistake that turns a promising AI product into a slow, costly mess fast today.

Planning an AI MVP for 2026? Use this as your pre-build checklist before you hire, scope, or sign anything.
Get Free Checklist!

How To Build An AI-Powered MVP Without Burning Your Startup Budget In 2026

The smartest way to build an AI MVP is not to start with the model. Start with the money risk.

Recent public pricing guides show wide AI app cost ranges, from lean MVP budgets to enterprise-level builds that can run far higher depending on data, architecture, and automation depth. That gap exists because AI scope changes everything. A basic assistant is not the same as a production-grade AI workflow with user data, security, testing, and monitoring.

So if you are comparing a mobile app development company in chicago or talking to a custom AI app development company, your first question should be simple:

What is the smallest version that can prove users care?

The Founder Rule

Do not build the app you want to pitch.

Build the app users can test.

Good MVP Goals

  • Help users finish one painful task faster
  • Reduce manual work in one repeated process
  • Improve one decision with better context
  • Prove one behavior users will come back for

That is it. Boring? Maybe. Smart? Absolutely.

Choose One AI Use Case First

AI products get expensive when founders add too many “smart” features too early.

You do not need a chatbot, recommendation engine, AI agent, document summarizer, voice assistant, and analytics layer in the first version. You need the one AI feature that makes the product worth opening again.

A custom AI app development company should push you to choose. If they say yes to every feature, that sounds nice in the meeting, but it gets expensive in the sprint.

Strong First AI Use Cases

Pick one from this kind of list:

  • AI onboarding assistant for SaaS users
  • Document summarization for legal, health, or finance workflows
  • Personalized recommendation engine for fitness or learning apps
  • AI support assistant that routes issues before human handoff
  • Predictive alerts for operations, logistics, or field teams
  • Smart search across internal company data

Weak First AI Use Cases

Avoid starting with:

  • “AI everywhere”
  • A generic chatbot with no workflow
  • Custom model training without enough data
  • Voice AI before text flow is proven
  • Full autonomy without guardrails

A mobile app development company in chicago should help you cut the weak ideas early. Same with a remote MVP partner if you are comparing vendors across markets.

Validate The Problem Before Writing Code

Code is not validation. A clickable prototype is not validation either.

Validation means real users confirm the problem is painful enough to solve, and they understand why AI makes the experience better.

Talk to 10 to 20 users before development. Ask about their current workflow. Watch where they lose time. Find the repeated pain. Then build around that.

Questions Worth Asking

  • What task do you repeat every week?
  • What part takes too long?
  • What data do you need before making a decision?
  • Where do current tools fail?
  • Would you trust AI to help with this task?
  • What would you still want to approve manually?

The Budget Win

When you validate first, you avoid building features nobody asked for. That means fewer screens, fewer APIs, fewer AI calls, fewer bugs, and less rework.

That is how you protect your runway.

Build A Thin Product, Not A Cheap Product

There is a big difference.

A cheap MVP cuts quality. A thin MVP cuts scope.

You still need solid architecture, clean UX, secure data flow, and reliable AI behavior. You just do not need every future feature in version one.

A custom AI app development company should know how to build thin without making the product fragile.

What A Thin AI MVP Includes

  • One primary user type
  • One core workflow
  • One AI-powered feature
  • One clear success metric
  • Basic admin visibility
  • Simple analytics
  • Human override or review step
  • Secure login and data handling

What It Leaves Out

  • Advanced dashboards
  • Multiple roles
  • Complex permissions
  • Custom model training too early
  • Extra integrations
  • Heavy automation before trust is proven

This is where founders need discipline. The product can be small. It cannot be sloppy.

Use Existing AI Models Before Custom Training

Custom model training sounds powerful. It also sounds expensive because it usually is.

Most startup MVPs should begin with existing APIs, open models, or retrieval-based systems before investing in custom training. This approach helps you test product value first and tune deeper later.

A mobile app development company in chicago that understands startup budgets will usually recommend this path unless your use case truly needs a custom model from day one.

Practical AI Stack Choices

Your technical plan may include:

  • A hosted LLM API for text reasoning
  • Embeddings for semantic search
  • RAG for answers from your own documents
  • Pre-built speech or vision APIs
  • Rule-based logic around AI output
  • Human review for sensitive actions

When Custom AI Makes Sense

Custom training may be worth it when:

  • You have unique proprietary data
  • Accuracy needs are very specific
  • Existing models fail repeatedly
  • You need domain-specific outputs
  • Your product advantage depends on the model itself

Until then, do not buy complexity before the market asks for it.

Design The UX Around Trust

Users do not trust AI because your homepage says “powered by AI.”

They trust AI when it explains itself, gives them control, and helps without making risky moves alone.

This is a product design problem, not only a machine learning problem.

A custom AI app development company should build the AI experience around user confidence. That means plain language, visible choices, and simple correction paths.

Trust-First UX Patterns

Use these in your MVP:

  • Show what data the AI used
  • Let users edit outputs
  • Add “approve before send” actions
  • Keep sensitive actions manual
  • Use clear error messages
  • Save feedback on bad suggestions
  • Show the next step, not ten options

Example

Do not say: “AI generated optimized workflow.”

Say: “Here are the 3 late tasks most likely to delay this project.”

Watch The Hidden Costs

AI MVP budgets burn in places founders forget to check.

The development quote is only one part. You also need to estimate model usage, hosting, storage, monitoring, QA, third-party APIs, and post-launch iteration.

Some public MVP guides place simple builds under $50K, while more complex agency-built or AI-powered products can go much higher based on features and technical depth. The exact number depends on scope, team model, and complexity, not just “AI” as a label.

Costs To Track Early

  • AI API usage
  • Database and file storage
  • Cloud hosting
  • Authentication tools
  • Analytics
  • Push notifications
  • QA testing
  • Security review
  • Maintenance
  • Post-launch support

A mobile app development company in dallas should be transparent about this. So should any serious partner you evaluate.

One Simple Budget Rule

Reserve 15% to 25% of your MVP budget for post-launch fixes and iteration.

Ship With Metrics, Not Hope

An MVP without tracking is just a guess with a login screen.

You need analytics from day one. Not a giant dashboard. Just enough data to know if people are reaching the “aha” moment.

Metrics That Matter

Track:

  • Activation rate
  • Time to first useful output
  • AI feature usage
  • Task completion rate
  • Repeat usage
  • Drop-off points
  • Manual corrections
  • User feedback on AI quality

The Investor Angle

Investors do not just want to see AI. They want proof that AI improves the product.

Show that users complete tasks faster. Show retention. Show lower manual workload. That story is stronger than any pitch deck slide.

Pick A Build Partner Like Your Budget Depends On It

Because it does.

The right partner will challenge your scope, protect your runway, and explain technical tradeoffs without hiding behind fancy words. The wrong partner will happily build every feature until the money runs out.

When you talk to a custom AI app development company, listen for how they think. Do they ask about users? Data? Launch metrics? Risk? Or do they jump straight into screens and timelines?

What To Ask Before Hiring

Ask:

  • What AI feature should we not build yet?
  • What can be manual in version one?
  • How will you control AI API cost?
  • How will we test AI output quality?
  • What data is stored and where?
  • What happens after launch?
  • Who owns the code?
  • What is included in QA?

Green Flags

Look for:

  • Product discovery before development
  • Clear phase-wise estimate
  • AI feasibility check
  • Security basics
  • Realistic timeline
  • Post-launch support
  • Founder-friendly communication

This is especially important if you are searching for a mobile app development company in chicago and comparing options against a mobile app development company in dallas or remote teams.

Build For Learning, Then Scale

Your first AI-powered MVP is not the final product. It is the fastest safe path to learning.

That means the code should be clean enough to improve, the architecture should not trap you, and the AI workflow should be easy to measure.

Your 2026 MVP Roadmap

A smart roadmap looks like this:

Phase 1: Discovery

Define user, pain, workflow, AI use case, and launch metric.

Phase 2: Prototype

Create clickable flow, test with users, cut anything unclear.

Phase 3: MVP Build

Develop core app, AI feature, backend, analytics, and basic admin.

Phase 4: Beta Launch

Release to a small group, monitor usage, collect feedback.

Phase 5: Improve

Fix friction, tune prompts, adjust workflow, plan the next feature.

Final Checklist Before You Spend

Keep this open before signing a proposal:

  • The user problem is specific
  • The AI use case is clear
  • The first version has one core workflow
  • Existing AI models are considered first
  • Data flow is mapped
  • Security basics are included
  • AI cost is estimated
  • UX includes user control
  • Analytics are planned
  • Post-launch budget is reserved
  • The team can explain tradeoffs clearly

And if you need a product-focused custom mobile app development company that understands MVPs, AI workflows, and startup budgets, Quokka Labs is built for that kind of work.

One more thing: a mobile app development company in Chicago should talk about learning loops, not just delivery dates.

Final Take

You can build an AI-powered MVP in 2026 without draining your startup budget. But only if you build smaller, sharper, and smarter.

Do not start with every feature. Start with one user, one pain, one AI workflow, and one metric that proves value.

And honestly, that is how startups survive long enough to win.

Reading a Psychrometric Chart Without Getting Lost

The psychrometric chart is one of the most information-dense diagrams in engineering. To a newcomer it looks like a tangle of curved and slanted lines. To an HVAC engineer it is a map: every point on it is a complete description of a sample of moist air, and every air-conditioning process is a path drawn across it. Once you can read the chart, sizing a cooling coil or diagnosing a condensation problem becomes a matter of plotting two points and reading off the distance between them.

This article explains the handful of properties the chart actually tracks, works a numerical example, and clears up the confusion most people have about relative humidity.

Why this calculation matters

Moist air is a mixture of dry air and water vapour, and almost everything that matters in HVAC depends on how much water that air is carrying. Size a cooling coil using temperature alone and you will under-size it badly, because removing humidity — condensing water out of the air — takes far more energy than simply lowering the temperature.

The chart also predicts condensation. If a surface in your system is colder than the air’s dew point, water will form on it. That is how you get dripping ducts, mould behind walls, and fogged-up windows. None of that is visible if you only think in terms of dry-bulb temperature.

The core properties

A psychrometric chart plots dry-bulb temperature on the horizontal axis and humidity ratio on the vertical axis. Every other line on the chart is derived from those two. The properties you need:

Humidity ratio (W) — the mass of water vapour per mass of dry air, in kg/kg or g/kg. This is the honest measure of “how much water.” It relates to the vapour partial pressure pv and total pressure P by:

W  =  0.622 * pv / (P - pv)

Relative humidity (RH) — the ratio of the actual vapour pressure to the saturation pressure at the same temperature:

RH  =  pv / psat(T)

RH is what thermostats display, but it is treacherous, because psat changes steeply with temperature. The same air can read 50 % RH in a warm room and 100 % RH after it cools — with no water added or removed.

Dew point — the temperature at which the air becomes saturated if cooled at constant humidity ratio. It is the condensation threshold.

Enthalpy (h) — the total heat content per kg of dry air, sensible plus latent:

h  =  1.006 * T  +  W * (2501 + 1.86 * T)     [kJ/kg dry air]

Enthalpy is the property that tells you the true energy load, because it already includes the latent heat tied up in the water vapour.

A worked example

Take a sample of room air at a dry-bulb temperature of 30 °C and 50 % relative humidity, at standard pressure P = 101.3 kPa. Find its humidity ratio, dew point, and enthalpy.

Step 1 — vapour pressure. The saturation pressure of water at 30 °C is about 4.25 kPa. At 50 % RH:

pv = 0.50 x 4.25 = 2.12 kPa

Step 2 — humidity ratio.

W = 0.622 x 2.12 / (101.3 - 2.12) = 0.0133 kg/kg = 13.3 g/kg

Step 3 — dew point. The dew point is the temperature where psat equals 2.12 kPa. That occurs at about 18.4 °C. So if any surface in the room drops below 18.4 °C — a cold water pipe, a single-glazed window — condensation will form on it.

Step 4 — enthalpy.

h = 1.006 x 30 + 0.0133 x (2501 + 1.86 x 30)
h = 30.2 + 34.0 = 64.2 kJ/kg dry air

That single enthalpy number, 64.2 kJ/kg, is what you would compare against the supply-air enthalpy to size a cooling coil. The temperature drop alone would not tell you the latent share.

Common mistakes

Treating relative humidity as an amount of water. It is not. RH is a ratio that depends on temperature. Air at 30 °C and 50 % RH holds far more water than air at 15 °C and 50 % RH. Always convert to humidity ratio before comparing two air states.

Sizing coils on sensible heat only. In humid climates the latent load — the energy to condense moisture — can equal or exceed the sensible load. Skip it and the coil runs but never controls humidity.

Confusing wet-bulb and dew-point temperatures. They are different points on the chart. Wet-bulb relates to evaporative cooling; dew point is the saturation temperature at constant W. They only coincide when the air is already saturated.

Forgetting that the chart is pressure-specific. A standard chart assumes sea-level pressure. At altitude the same dry-bulb and RH give a different humidity ratio, and a sea-level chart will mislead you.

Try the interactive NovaSolver calculator

A paper chart is excellent for intuition but slow for iteration. The psychrometrics calculator on NovaSolver takes the dry-bulb temperature and relative humidity and returns the rest of the air state — humidity ratio, dew point, enthalpy, and specific volume — while plotting the state point on a live psychrometric chart. It removes the chart-reading error while keeping the chart’s logic visible.

Related calculators

The complete set is in the HVAC tools hub.

Closing note

The psychrometric chart looks intimidating because it carries so much information at once, but the underlying idea is simple: moist air has a temperature and a moisture content, and everything else follows. Learn to think in humidity ratio rather than relative humidity, always check the dew point against your coldest surface, and use enthalpy when energy is the question. Do that and the chart stops being a tangle of lines and becomes the most useful single page in the HVAC engineer’s toolkit.

LMR-BENCH: Can LLM Agents Reproduce NLP Research Code? (EMNLP 2025)

A research team from the University of Texas at Dallas published LMR-BENCH at EMNLP 2025, asking a specific question: can LLM agents reproduce the core implementation from an NLP research paper when given the paper, a partially masked codebase, and explicit instructions?

This is harder than it sounds. And the benchmark design is smart enough to be worth understanding in detail.

Sources: arXiv 2506.17335 | ACL Anthology | GitHub

What the benchmark actually tests

LMR-BENCH contains 28 reproduction tasks drawn from 23 NLP papers published in ACL, EMNLP, NAACL, and AAAI over the past five years. Each task follows the same structure:

  1. Paper: the full PDF
  2. Masked repository: a real codebase from the paper, but with one or more critical functions replaced by # TODO: implement stubs
  3. Implementation instruction: a natural language description of what the masked function should do, including cross-file dependencies and design intent

The agent’s job is to generate patch code that fills the stubs correctly.

This tests something distinct from “can an LLM write a function from a docstring.” The function body has to match what the paper describes, use the surrounding codebase’s conventions, and pass unit tests against the paper’s reference implementation.

The nine task categories

Category What gets masked
Tokenization Custom tokenizer logic
Attention mechanism Scaled dot-product or custom attention
Positional encoding RoPE, ALiBi, learned variants
Loss function Custom training objectives
Data preprocessing Dataset-specific transforms
Model architecture Layer definitions, custom blocks
Training procedure Optimizer steps, gradient modifications
Decoding strategy Beam search variants, constrained decoding
Evaluation metric BLEU variants, task-specific metrics

The hardest category is model architecture: reproducing a custom layer requires reading across multiple files to understand tensor shapes, class inheritance, and forward pass conventions — exactly the kind of multi-file reasoning that current LLMs struggle with.

The easiest is evaluation metric: formulas are usually self-contained, well-documented in the paper, and don’t require deep codebase knowledge.

How masking works in practice

Here’s what a masked task looks like (synthetic example based on paper methodology):

# Original in paper's codebase: rotary_embedding.py
def apply_rotary_emb(xq, xk, freqs_cis):
    """Apply rotary embeddings to query and key tensors."""
    xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
    xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
    freqs_cis = reshape_for_broadcast(freqs_cis, xq_)
    xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
    xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)
    return xq_out.type_as(xq), xk_out.type_as(xk)

# Masked version (what the agent receives):
def apply_rotary_emb(xq, xk, freqs_cis):
    """Apply rotary embeddings to query and key tensors."""
    # TODO: implement
    # Instruction: Apply rotary position embeddings to xq and xk.
    # Use torch.view_as_complex for complex number representation.
    # freqs_cis shape must be broadcast-compatible with xq_.
    # Return float tensors matching input dtype.
    raise NotImplementedError

The info.json for this task would also specify which files the agent should read (reshape_for_broadcast definition lives in utils.py, for example).

Dual evaluation: unit tests + LLM-as-judge

LMR-BENCH scores agents on two axes:

Axis 1 — Functional correctness (unit tests)
Numerical equivalence against the reference implementation. The agent’s patch must produce the same tensor outputs as the original function.

Axis 2 — Implementation fidelity (LLM-as-judge)
GPT-4o reads the paper’s algorithm description and the agent’s code, then scores whether the implementation actually follows the described method — even if it passes unit tests through an equivalent but differently structured approach.

This dual axis matters because:

  • A function can pass unit tests but use a different algorithm (memorized shortcut)
  • A function can fail unit tests due to floating-point differences but be conceptually correct

Both axes tell you different things about the agent’s reasoning.

What the results show

The paper doesn’t release a full leaderboard in the public arXiv version, but the findings indicate:

  • o3-mini (high compute) was the best-performing model tested
  • Pass@1 rates ranged roughly from 20% to 60% across task categories
  • Multi-file reasoning was the single biggest differentiator: models that could trace function calls across 3+ files significantly outperformed those that stayed in the target file
  • Simply giving the model the paper PDF without the masked code resulted in worse performance than giving both — the code context matters more than the paper text for reproduction tasks

The last point is counterintuitive. You’d expect the paper’s equations to be the key signal. But the surrounding codebase (tensor shapes, variable naming, utility functions) constrains the solution space more tightly than the abstract algorithm description.

Why this benchmark matters for developers

If you’re building an AI-assisted research coding tool (or evaluating whether an agent can help you implement a paper), LMR-BENCH is the most realistic evaluation framework available. The alternatives:

  • HumanEval / MBPP: function-level, no paper context, no cross-file reasoning
  • SWE-bench: bug fixing in large codebases, different skill set from paper reproduction
  • APPS: competitive programming, not research implementation

LMR-BENCH specifically targets the “I read a paper, now implement it” workflow — which is what most ML engineers actually do.

Running the benchmark yourself

The benchmark repo requires Python ≥ 3.12 and supports any LLM backend through its evaluation harness:

git clone https://github.com/du-nlp-lab/LMR-Bench
cd LMR-Bench
pip install -r requirements.txt

# Run a single task with Claude
python evaluate.py 
  --task benchmark/rotary_emb_task/ 
  --model claude-opus-4-7-20251001 
  --api-key $ANTHROPIC_API_KEY

The evaluation harness handles: sending the paper + masked code to the model, collecting the patch, running unit tests, and recording fidelity scores.

What to expect if you run it

Based on the paper’s findings, expect:

  • Evaluation metric tasks: 50–60% pass@1 with a capable model
  • Model architecture tasks: 20–30% pass@1, sometimes lower
  • Most failures: not wrong algorithm, but wrong tensor handling — shape mismatches from not reading the surrounding code carefully enough

If you’re using this to evaluate your own agent, the architecture tasks are the most informative discriminator between models.

The broader picture

LMR-BENCH reveals a gap that matters: LLMs can explain papers well and can write code well, but the intersection — implement exactly what this paper describes, in this codebase, with these constraints — is still hard. The benchmark gives that gap a number.

For the AI research community, this is also a forcing function: if you want your paper to be reproducible by an LLM agent, write clearer implementation instructions and reduce cross-file dependencies in your codebase.

Paper: Shuo Yan et al., “LMR-BENCH: Evaluating LLM Agent’s Ability on Reproducing Language Modeling Research,” EMNLP 2025. arXiv:2506.17335. All results cited from the published paper. PoC evidence in data/lab-runs/lmr-bench-llm-reproduce-nlp-research-code-paper-poc-2026.md.

How to turn text into colors (without AI)

No AI gigachad

You definitely know that you can generate an image from text by simply feeding the prompt to Nano Banana or an equivalent. What if I tell you that you can generate a color palette out of your text, without any help from AI?

No AI gigachad

My idea is nothing complex. Just look at the HSL color model.

HSL color model

As you can see, to represent color, we need three numbers – hue degrees, saturation percentages, and lightness percentages. All we have to do is get those numbers out of text.
There are a few ways to do so, but one of the most common, logical, and foolproof is to create an alphabet first and then analyze character frequency second. Let me show you.
For starters, we need a text. I took Christmas from Lingua.com. If we ran character frequency analysis (as is), we would end up with something like this:

[
  {"character": " ", "count": 307},
  {"character": "e", "count": 195},
  {"character": "a", "count": 137},
  {"character": "t", "count": 134},
  {"character": "i", "count": 120},
  {"character": "s", "count": 115},
  {"character": "o", "count": 97},
  {"character": "h", "count": 94},
  {"character": "r", "count": 92},
  {"character": "n", "count": 91},
  {"character": "l", "count": 86},
  {"character": "d", "count": 79},
  {"character": "c", "count": 49},
  {"character": "m", "count": 43},
  {"character": "y", "count": 39},
  {"character": "p", "count": 35},
  {"character": ",", "count": 31},
  {"character": "g", "count": 28},
  {"character": "f", "count": 27},
  {"character": "u", "count": 21},
  {"character": "b", "count": 20},
  {"character": "w", "count": 20},
  {"character": "v", "count": 11},
  {"character": "C", "count": 10},
  {"character": ".", "count": 9},
  {"character": "S", "count": 7},
  {"character": "k", "count": 7},
  {"character": "-", "count": 6},
  {"character": "A", "count": 5},
  {"character": "(", "count": 5},
  {"character": ")", "count": 5},
  {"character": "'", "count": 5},
  {"character": "n", "count": 4},
  {"character": "j", "count": 4},
  {"character": "J", "count": 3},
  {"character": "T", "count": 2},
  {"character": "N", "count": 2},
  {"character": "x", "count": 2},
  {"character": "G", "count": 2},
  {"character": "L", "count": 2},
  {"character": """, "count": 2},
  {"character": "B", "count": 2},
  {"character": "U", "count": 1},
  {"character": "2", "count": 1},
  {"character": "5", "count": 1},
  {"character": "D", "count": 1},
  {"character": "P", "count": 1},
  {"character": "q", "count": 1},
  {"character": "/", "count": 1}
]

Unsurprisingly, the whitespace is the most frequent symbol – it’s a common thing for texts in the English language and other Western languages. If we were to process letters only (case-insensitive), the array would look like this:

[
  { "character": "e", "count": 195 },
  { "character": "a", "count": 142 },
  { "character": "t", "count": 136 },
  { "character": "s", "count": 122 },
  { "character": "i", "count": 120 },
  { "character": "o", "count": 97 },
  { "character": "h", "count": 94 },
  { "character": "n", "count": 93 },
  { "character": "r", "count": 92 },
  { "character": "l", "count": 88 },
  { "character": "d", "count": 80 },
  { "character": "c", "count": 59 },
  { "character": "m", "count": 43 },
  { "character": "y", "count": 39 },
  { "character": "p", "count": 36 },
  { "character": "g", "count": 30 },
  { "character": "f", "count": 27 },
  { "character": "b", "count": 22 },
  { "character": "u", "count": 22 },
  { "character": "w", "count": 20 },
  { "character": "v", "count": 11 },
  { "character": "j", "count": 7 },
  { "character": "k", "count": 7 },
  { "character": "x", "count": 2 },
  { "character": "q", "count": 1 }
]

Our next task is to take the HSL color wheel

HSL color wheel

Form a “string” out of symbols and “wrap” it around the wheel

Color wheel with characters

By dividing the wheel into equal parts according to our “alphabet,” we got the hues. The frequency of symbols got us saturation. And luminosity has a default value – 50% for pure colors. Of course, nothing stops you from making luminosity a variable.

All right, let’s go to code:

First step, cleaning up and normalising text data according to our preferences – include or not whitespaces, numbers, etc.

    let rawChars = text.split('');

    const filteredChars = rawChars.filter(char => {

        // any whitespace
        const isSpace = /p{Separator}/u.test(char);

        // punctuation in any script
        const isPunct = /p{P}/u.test(char);

        // any numeric digit in any language
        const isDigit = /p{N}/u.test(char);

        // any letter
        const isLetter = /p{L}/u.test(char);

        // any symbol
        const isSymbol = /p{S}/u.test(char);

        if (!settings.includeWhitespace && isSpace) return false;
        if (!settings.includePunctuation && isPunct) return false;
        if (!settings.includeDigits && isDigit) return false;
        if (!settings.includeSymbols && isSymbol) return false;
        return true;
    });

    const workingText = filteredChars.join('');

Second is making a frequency table

  const charMap = {};
  filteredChars.forEach(char => {
      const key = settings.caseSensitive ? char : char.toLowerCase();
      charMap[key] = (charMap[key] || 0) + 1;
  });

Next is making auxiliary processing

  let minCodePoint = Infinity;
  let maxCodePoint = -Infinity;
  let maxFreq = 0;

  const data = entries.map(([char, count]) => {
    const preparedChar = settings.caseSensitive ? char : char.toLowerCase();
    const codepoint = preparedChar.codePointAt(0);
    if (codepoint < minCodePoint) minCodePoint = codepoint;
    if (codepoint > maxCodePoint) maxCodePoint = codepoint;
    if (count > maxFreq) maxFreq = count;
    return { char, count, codepoint };
  });

  return { 
    data, 
    minCodePoint, 
    maxCodePoint, 
    maxFreq, 
    range: (maxCodePoint - minCodePoint) || 1 
  };

Finally, HSL calculations

export const getPalette = (context) => {
  if (!context) return [];

  return context.data.map(({ char, count, codepoint }) => {
    const h = Math.round(((codepoint - context.minCodePoint) / context.range) * 360);
    const s = Math.round((count / context.maxFreq) * 100);
    const l = 50;

    return {
      char,
      count,
      h, s, l,
      hsl: `hsl(${h}, ${s}%, ${l}%)`
    };
  });
};

Anyway, let’s see what the palette for our Christmas text will look like. I made two types of data visualization for that – Tile Chart and Bar Chart.

Tile Chart and Bar Chart with corresponding characters and colors

A lot of interesting stuff is going on here. We can go the full naive approach, collect all the highest-scoring colors, and call it a day.

Top-5 and top-10 colors

But here is the problem – it’s way too “loud”, too vivid and intense to be a useful color palette.

Top-5 colors visualization at Coolors

One way to overcome it will be to use the weighted clustering algorithm. Long story short:
1) we split the 360° color wheel into 30° sectors (buckets), since the standard color wheel has 12 colors, and 360/12 is 30.
2) we push each color into the corresponding bucket
3) we calculate the weight of each bucket (high saturation is high weight)
4) we calculate an average hue and average saturation for the sum of colors in each bucket
5) ???
6) PROFIT (there are no guarantees it will make a suitable palette, though, so proceed with caution)

Straight to the code:

export default function getWeightedClusters(palette: PaletteItem[]): ColorCluster[] {
    const buckets: Bucket[] = Array.from({ length: 12 }, () => ({
      totalWeight: 0,
      sumX: 0,
      sumY: 0,
      count: 0,
      originalColors: []
    }));

    palette.forEach(item => {
      // 1. Parse HSL
      const {h, s, l, char} = item;

      // 2. Calculate Weight (Favor high saturation)
      const weight = Math.pow(s, 2) + 1; // +1 to give very desaturated colors a tiny vote

      // 3. Determine Bucket (0-11)
      // We shift by 15 degrees so the primary colors are in the center of buckets
      const bucketIndex = Math.floor(((h + 15) % 360) / 30);

      // 4. Convert Hue to Vector (Radians)
      const rad = (h * Math.PI) / 180;
      const x = Math.cos(rad) * weight;
      const y = Math.sin(rad) * weight;

      // 5. Accumulate
      const b = buckets[bucketIndex];
      b.totalWeight += weight;
      b.sumX += x;
      b.sumY += y;
      b.count++;
      b.originalColors.push({ h, s, l, weight, char });
    });

    // 6. Finalize Clusters
    return buckets
      .map((b, index) => {
        if (b.count === 0) return null;

        // Convert vector average back to Hue
        let avgHue = (Math.atan2(b.sumY, b.sumX) * 180) / Math.PI;
        if (avgHue < 0) avgHue += 360;

        // Weighted average Saturation
        const avgSat = b.originalColors.reduce((acc, c) => acc + (c.s * c.weight), 0) / b.totalWeight;

        const bucketChars = b.originalColors.map(item => {return {char: item.char, color: `hsl(${item.h}, ${item.s}%, 50%)`}});

        return {
          id: index,
          representativeHue: Math.round(avgHue),
          representativeSat: Math.round(avgSat),
          strength: b.totalWeight,
          density: b.count,
          chars: bucketChars
        } as ColorCluster;
      })
      .filter((c): c is ColorCluster => c !== null);
  }

And visualization (I’ve made a color wheel and pack of cards):

Cluster colors

Clusters as group of cards

Clusters as colorwheel

Cool, huh? But it works not only with Latin alphabet-based languages, but (theoretically) supports everything.

Here is text in Japanese I took from this site, Reading Passage 3

Character chart for japanese text

Tile chart for japanese text

Clusters for japanese text

That’s actually only a small part of what could be done. We can calculate the average hue and saturation of the text and generate a palette from just one color, for example.

Palette, generated from one color

You can look at the code here
You can look and play with the tool here

Thanks for coming to my TED talk. Feel free to reach me in comments or anywhere.

OK bye

P.S. The cover image was created using a color palette derived from this post. R is for recursion.

react-render-profile-mcp v0.3.1 – 4 new diagnostic tools for React Compiler, hydration, Zustand, and state cascades

A few weeks ago I published a post about react-render-profile-mcp — an MCP server that decodes React DevTools Profiler exports so AI agents can actually diagnose render performance instead of guessing at raw fiber IDs.

v0.1 shipped with 5 tools. v0.3.1 adds 4 more, each targeting a class of problems the original couldn’t touch. Here’s what’s new. 🐸

What was already there (v0.1)

Quick recap for anyone who missed the first post:

  • get_render_summary — total commits, render time, top components, lifecycle anomaly flags
  • find_spurious_renders — components that re-rendered with no actual prop/state change
  • get_hottest_components — ranked by CPU self-time
  • trace_render_cascade — what triggered a commit and what re-rendered as a result
  • suggest_memoization — ROI-scored React.memo recommendations

These covered the “something is rendering too much” class of problems. What they couldn’t tell you: why your React Compiler isn’t helping, where hydration broke, which Zustand selector is in a loop, or how deep a context update actually propagates.

What’s new in v0.3.1

1. analyze_compiler_efficacy

React Compiler (React 19) and manual React.memo should eliminate spurious renders. Often they don’t — because of inline object allocations or unstable parent refs that bypass memoization entirely.

This tool computes an Invalidation Index per component:

I = (spurious_count / total_count) × wasted_ms

High index = memoization is present but not working. The tool tells you exactly why:

{
  "severity": "CRITICAL",
  "component_name": "ProductList",
  "ineffective_render_count": 23,
  "wasted_ms": 84.3,
  "trigger_cause": "UNSTABLE_PARENT_PROP_REFERENCE",
  "recommendation": "Memoize parent props with useMemo/useCallback or hoist static objects out of the parent render function."
}

Without this, your agent might suggest adding React.memo to a component that already has it — and has it for a reason that makes it useless.

2. diagnose_hydration_and_suspense

Two separate problems, one tool:

Hydration mismatches — when React discards server HTML and remounts the entire tree from scratch. Shows up as an abnormally long initial mount with a spike of unmounts immediately after. The tool flags these as HYDRATION_MISMATCH_RECOVERY with the affected Suspense boundaries and blocking duration.

Suspense waterfalls — nested boundaries fetching sequentially instead of in parallel. Detected by measuring the gap between consecutive Suspense resolves against a configurable waterfall_threshold_ms (default: 100ms).

{
  "severity": "WARNING",
  "anomaly_type": "NESTED_MOUNT_FETCH_WATERFALL",
  "root_component": "ProfileDetails",
  "blocking_duration_ms": 120.5,
  "recommendation": "Prefetch data at the parent level or use Promise.all."
}

3. evaluate_external_store_performance

Zustand and Redux with useSyncExternalStore have two failure modes that are hard to catch manually:

Unstable selector object allocation — a selector returns a new object reference every call, triggering rapid consecutive renders. The tool detects components that render multiple times in consecutive frames and flags the selector as the cause.

Sync concurrency bypass — a heavy store update runs synchronously in a high-priority lane, blocking the main thread. Should be in startTransition instead.

{
  "severity": "CRITICAL",
  "impacted_components": ["CartSummary"],
  "is_infinite_loop": true,
  "trigger_cause": "UNSTABLE_SELECTOR_OBJECT_ALLOCATION",
  "recommendation": "Wrap the selector in useCallback or return primitive values."
}

4. trace_state_cascade_footprint

Given a commit index, this reconstructs the virtual component tree and measures how far an update actually propagated:

  • Which component triggered the update
  • Whether it went through a context provider or a store subscriber
  • How many levels deep it reached
  • How many consumer components re-rendered
{
  "severity": "HIGH_FOOTPRINT",
  "update_trigger_source": "ThemeButton",
  "propagation_channel": "CONTEXT_PROVIDER",
  "cascade_render_depth": 7,
  "rendered_consumer_count": 28,
  "recommendation": "Split the context provider or memoize its value and children."
}

This is the tool that answers “why did 28 components re-render when I clicked one button.”

Updated: find_spurious_renders trigger classification

The existing tool now classifies why a render was spurious, not just that it was:

  • UNSTABLE_PARENT_REF — parent passed a new object/array/function reference with identical values
  • CONTEXT_UPDATE — context changed, but this component doesn’t actually use the changed value
  • INTENTIONAL_CONCURRENT_YIELD — React’s scheduler, not a bug

This matters because React.memo fixes the first case but can’t help with the second. The recommendation now reflects the correct fix path.

Recommended agent workflow (updated)

1. get_render_summary              → overview + lifecycle_anomaly flags
2. find_spurious_renders           → classify unnecessary renders by trigger type
3. analyze_compiler_efficacy       → check where React.memo / Compiler is being bypassed
4. diagnose_hydration_and_suspense → catch hydration recovery + Suspense waterfalls
5. evaluate_external_store_performance → Zustand/Redux selector loops + sync bypasses
6. trace_state_cascade_footprint   → propagation depth for expensive commits
7. suggest_memoization             → ROI-scored final recommendations

Setup (unchanged)

{
  "mcpServers": {
    "react-render-profile": {
      "command": "npx",
      "args": ["-y", "react-render-profile-mcp"]
    }
  }
}

Export a profile from React DevTools → Profiler tab → Record → Save. Pass the .json path as profile_path to any tool.

npm: react-render-profile-mcp
GitHub: vola-trebla/react-render-profile-mcp

Happy to answer questions about the hydration detection heuristics or the Invalidation Index math. And if you’re hitting a React performance pattern this doesn’t cover yet — open an issue. 🐸

n8n for Beginners: Build Your First Automation in 15 Minutes (No Coding Required)

I built my first n8n workflow in 2023 because I was tired of paying $49/month for Zapier to send a single email when a form was submitted.

Three years later, n8n runs 40+ automations across my business — invoicing, lead capture, daily reports, AI customer support — all for $0/month on a self-hosted server.

If you’re just getting started with n8n, this guide gets you from “what is n8n” to your first running workflow in under 15 minutes.

What Is n8n?

n8n is an open-source workflow automation tool. Like Zapier or Make.com, but:

  • Free forever when self-hosted (no per-task fees)
  • Code nodes — run real JavaScript or Python when you need logic
  • 1,000+ integrations — Gmail, Slack, Notion, Airtable, Postgres, HTTP requests, and more
  • Your data stays on your server — no SaaS company processing your customer emails

The catch: you need to install it. Takes about 2 minutes.

Install n8n (3 Options)

Option 1 — npx (fastest, no Docker required):

npx n8n

Open http://localhost:5678. Done.

Option 2 — Docker:

docker run -it --rm --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n

Option 3 —!n8n Cloud:

Sign up at n8n.io — free trial, no install needed. Good for testing before self-hosting.

Core Concepts (Learn These 5 Terms First)

Term What It Means
Node One step in your workflow (e.g., “Send Gmail”)
Trigger The first node — what starts the workflow
Connection The arrow linking nodes together
Workflow The full chain from trigger to last step
Execute Running the workflow manually or on a schedule

That is really it. Everything else is just different types of nodes.

Build Your First Workflow: Form Submission to Email Notification

Let us build something real: whenever someone submits a form, you get an email notification.

Step 1: Create a new workflow

In n8n, click + New Workflow in the top right.

Step 2: Add a Webhook trigger

  • Click the + button
  • Search for “Webhook” and select it
  • Copy the Test URL (looks like http://localhost:5678/webhook-test/abc123)

Step 3: Add a Gmail node

  • Click + after the Webhook node
  • Search “Gmail” and select Send Email
  • Connect your Gmail account (click Create New Credential)
  • Fill in To (your email), Subject: New Form Submission, Message: {{ $json.body.name }} submitted the form

Step 4: Test it

  • Click Execute Workflow
  • In a new tab, open the Test URL with ?name=TestUser appended
  • Watch data flow through the nodes in real time
  • Check your inbox

Step 5: Activate

Toggle Active in the top right. The workflow now runs automatically.

5 Beginner-Friendly n8n Workflows to Build Next

1. Email Auto-Responder

Keyword-based auto-replies. Someone emails “pricing” and they get your price list automatically.

Nodes: Gmail Trigger + Code (keyword match) + Gmail (send reply) + Google Sheets (log)

2. Appointment Reminder

Pull upcoming Google Calendar events and send email/SMS reminders 24h before.

Nodes: Schedule Trigger (hourly) + Google Calendar + Code (filter window) + Gmail

3. Lead Capture to CRM

Form submission comes in, score the lead by role/company size, log to Sheets, alert on Slack if hot.

Nodes: Webhook + Code (scoring) + Google Sheets + IF + Slack

4. Daily Business Report

Every morning at 7am: pull sales data from Sheets, calculate metrics, email yourself a summary.

Nodes: Schedule Trigger + Google Sheets + Code (calculate KPIs) + Gmail

5. AI Customer Support Bot

Incoming support ticket comes in, OpenAI writes a draft reply, Gmail sends it to the customer.

Nodes: Webhook + HTTP Request (OpenAI API) + Gmail

Common Beginner Mistakes

Forgetting to Activate

Workflows only run automatically when toggled Active. If nothing is happening, check the toggle first.

Using the Test URL in production

The Test URL (/webhook-test/) only works when you have n8n open and are inside the workflow editor. Use the Production URL (/webhook/) for live traffic.

Not handling errors

Add an Error Trigger workflow that sends you a Slack or email alert when any other workflow fails. Without this, you will never know when something breaks.

Overcomplicating the first workflow

Start with 3 nodes. Get it working. Then add complexity. Starting with a 20-node diagram before running a single test always ends in confusion.

Useful n8n Resources

  • Official docs: docs.n8n.io (excellent, very well maintained)
  • Template library: n8n.io/workflows (2,000+ community workflows)
  • Community forum: community.n8n.io (active, moderators respond fast)
  • YouTube: search “n8n tutorial 2026” for video walkthroughs

Skip the Learning Curve: Ready-Made Templates

If you want production-ready workflows without building from scratch, I packaged 15 battle-tested automations covering the most common business use cases.

Each template includes:

  • Full workflow JSON (import in 30 seconds via Settings + Import Workflow)
  • Step-by-step setup guide (under 5 minutes each)
  • Customization notes

Free template: Email Auto-Responder JSON is available in the FlowKit GitHub repo

Full pack (15 templates, $97): stripeai.gumroad.com

Templates in the pack: Email Auto-Responder, Social Media Cross-Poster, Invoice Generator, AI Customer Support Bot, Price Monitor, Lead Capture to CRM, Appointment Reminder, Content Repurposer, Daily Report Generator, Webhook to Database, RSS Social Auto-Poster, File Organizer, Claude AI Agent with Memory, Telegram AI Bot, and more.

5-Day Starter Plan

  • Day 1: Install n8n, build the Webhook-to-Gmail workflow above
  • Day 2: Add a Code node, do a simple string transformation on the incoming data
  • Day 3: Read from and write to a Google Sheet
  • Day 4: Call an external API with the HTTP Request node
  • Day 5: Automate one task you currently do manually every day

The fastest way to learn n8n is to automate something that actually annoys you. Start there.

Questions? Drop them in the comments — I read and respond to every one.

Why Mobile Users Leave a Website Within Seconds

Why Mobile Users Leave a Website Within Seconds

It’s rarely about the content. Usually, they’re gone before they even see it.

There’s a specific kind of frustration that mobile users almost never talk about — because it disappears too fast to complain about.

You tap a link. The screen goes white. You wait. Nothing happens fast enough. So you back out. You’ve already forgotten what you were looking for.

The whole thing took maybe four seconds. But those four seconds were enough.

The Phone Is a Different Psychological Environment

This is the part that gets missed in most conversations about mobile UX.

When someone is at a desk with a laptop open, there’s an implicit commitment. They sat down. They opened the browser. They have a task. The context creates a baseline of patience.

A phone is different. It gets picked up between things — between floors on an elevator, between bites at lunch, in the thirty seconds before a meeting starts. There’s no commitment. No task framing. Just a small gap in the day that a quick tap was supposed to fill.

That changes everything about what “acceptable” feels like.

On desktop, waiting eight seconds feels annoying. On mobile, in that same mindless-scroll moment, waiting three seconds feels like an eternity — because the gap you were trying to fill is already closing. The elevator doors open. The food arrives. The meeting starts. The phone goes back in your pocket.

The site never stood a chance.

Attention Isn’t Lost — It Was Never Really Committed

Most UX writing talks about “losing user attention.” But on mobile, attention was never fully given in the first place.

Mobile browsing is an activity done alongside other things. It’s layered on top of real life, not carved out from it. A mobile user’s attention is usually partial, borrowed, and easily pulled back by whatever is happening around them.

What this means practically: the first few seconds of loading aren’t just a technical delay. They’re a window in which the user is actively reconsidering whether this is worth their fragmented attention at all.

A slow response during that window doesn’t just test patience. It actively hands the decision back to the user — and their default answer, in that distracted moment, is almost always no.

Server Latency — The Invisible Tax on Every Interaction

Most people don’t think about server latency. They think about whether the content is good.

But server latency is what the user experiences before content even exists on their screen. It’s the gap between “I tapped this” and “something appeared.” And in mobile contexts, that gap is weighted differently than anywhere else.

Every additional 100ms of latency adds up. Not in a way users can articulate — they won’t say “the TTFB was poor.” They’ll say “it felt slow” or “something felt off” or, more often, they won’t say anything at all. They’ll just leave and not come back.

Geography matters too. If a server is far from the user and there is no well-distributed CDN in place, every request starts with a small delay. Multiply that across every page transition, every scroll, every tap — and the cumulative effect on perceived speed is significant, even when individual numbers seem small.

UX Friction Is Mostly Invisible Until It Isn’t

Friction is the right word because it describes the physics accurately. Things slow down. Movement becomes effortful. The user has to push harder than they expected.

On mobile, friction shows up in places that are easy to overlook during development: tap targets that are slightly too small, text that’s a size too fine for thumb-scroll reading, page jumps caused by late-loading images, modals that appear without warning and block content.

None of these individually seem like dealbreakers. Together, they create a texture to the experience that users register as “this doesn’t feel right” — without knowing why.

The interesting thing about UX friction is that it often gets attributed to the wrong cause. A user who bounces because a tap target was two pixels too small will remember the site as “slow” or “confusing,” not “had slightly misaligned UI elements.” The friction translates into a general negative impression, not a specific complaint.

That makes it hard to debug — and easy to underestimate.

The Psychology of Loading — What the Brain Does While Waiting

Waiting isn’t passive. While a page loads, the user’s brain is doing several things simultaneously.

It’s evaluating whether the wait is going to be worth it. It’s assessing whether there might be a faster alternative nearby. It’s monitoring the environment for anything more immediately relevant — a vibration, a sound, movement in the periphery.

A loading spinner or progress bar addresses this to some degree. Something visible happening creates a sense of process — the brain interprets it as progress rather than stasis. But this only works within a narrow window. Beyond a few seconds, even visible loading feedback starts to feel like waiting, not anticipating.

What mobile users actually respond to is incremental content. The moment something — anything — appears on screen, the psychological dynamic shifts. The user has something to look at. The question changes from “is anything happening?” to “is this worth staying for?” That’s a much more favorable question to be answering.

Sites that load a visible structure first — text before images, layout before media — hold users longer than sites that load nothing until everything is ready. The technical difference might be small. The psychological difference is not.

Mobile Optimization Is a Commitment, Not a Feature

There’s a category of platform that treats mobile optimization as a checkbox. Responsive layout: checked. Font size: readable. Done.

That approach shows. Users feel it, even without the vocabulary to describe what’s wrong.

Real mobile optimization starts at the infrastructure level — how requests are routed, how assets are compressed, how rendering is prioritized on lower-powered hardware. It continues through the interaction layer — how touch events are handled, how navigation responds to gesture, how state is preserved across interruptions.

And it extends to the content itself — whether media files are appropriately sized for the connection type being used, whether the most critical content is prioritized in the loading sequence, whether the experience degrades gracefully when conditions aren’t ideal.

That last part matters more on mobile than anywhere else. Conditions on mobile are rarely ideal. Network quality varies. Background processes compete. Users switch apps mid-session and expect to return seamlessly.

Platforms that treat these realities as edge cases tend to feel unreliable. Platforms built around them tend to feel fast — even when raw speed metrics look similar.

A practical example of this kind of mobile-first approach can be seen on the official website, where speed, lightweight interaction, and mobile usability are treated as part of the core experience rather than an afterthought.

Why This Keeps Mattering

Mobile traffic continues to grow. Attention continues to fragment. The gap between a platform that loads in 1.5 seconds and one that loads in 3.5 seconds — barely noticeable on a spec sheet — translates to measurably different retention rates in practice.

Users don’t give extra credit for almost fast. They don’t consciously notice when things go well. They only register, somewhere below articulation, that this place felt easy to be in.

That feeling is what they come back for.

Not the content. Not the features. The feeling that being here requires nothing extra from them — no patience, no effort, no second-guessing whether the tap registered.

The sites that earn that feeling aren’t necessarily the biggest or the best-resourced. They’re the ones that decided mobile users deserve the same quality of experience as everyone else — and then actually built for it.

Written from an interest in how small technical decisions translate into felt human experience.

I Spent $0.37 Testing Google’s Antigravity 2.0 Agent API — Here’s Every Bug You’ll Hit (and How to Fix Them)

TL;DR: Antigravity 2.0 cut my 90‑min dependency audit to 14 min for $0.044. It caught a critical CVE I’d missed for months — and hallucinated an unreleased package version. Here’s the production checklist, working code, and every bug I hit so you don’t have to.

Google I/O 2026 made agentic workflows feel real. Rather than just watching the demos, I tested Google’s managed agents on an actual dependency audit across 14 production microservices. The result: the workflow was genuinely faster, but it also exposed a handful of production problems you’d want to solve before trusting it in the wild.

This article isn’t just about saving 76 minutes. It’s about what agentic runtimes change in how we build, verify, and supervise automation — and what still breaks when you take them off the demo stage.

Note: Antigravity is Google’s preview managed-agent runtime, announced at I/O 2026 — that’s what I tested.

How the Tokens Actually Move

Before diving into code, it helps to see how tokens flow between agents, the sandbox, and the outside world. The flow determines where your cost and latency actually come from.

User Task 
   │
   ▼
Scanner Agent (17.3K tokens) 
   │ writes report.json
   ▼
Security Agent (13K tokens) 
   │ enriches with CVEs
   ▼
Changelog Agent (1.5K tokens) 
   │ generates markdown
   ▼
PR Agent (3K tokens) ──→ GitHub PRs

All four agents run inside the same managed sandbox, which persists across calls so you don’t re‑ingest everything. Token tally from one clean run:

  • Scanner: 5,600 input, 11,740 output (17,340 tokens)
  • Security: 2,100 input, 10,902 output (13,002 tokens)
  • Changelog: 350 input, 1,200 output (1,550 tokens)
  • PR Agent: 400 input, 2,600 output (3,000 tokens)

Total: 34,892 tokens across four stages. The environment itself costs nothing extra—you just pay for tokens at the model’s per‑token rate. For this preview, I used gemini‑3.5‑flash‑preview at $0.0005/1K input and $0.0015/1K output. The sandbox is bundled in, which changes the economics completely compared to spinning up your own container.

💡 Why this matters: Because all agents share the same environment, the Security Agent doesn’t need to re‑scan the repo. It just reads the JSON that’s already sitting there. That’s where a lot of cost gets saved.

Real Numbers: Time and Money

I compared three ways of doing the exact same 14‑service audit: doing it manually (me, a human), using Antigravity’s Managed Agents, and running the same prompts on a cheap cloud VM with a normal Gemini API call and custom orchestration.

Metric Manual Antigravity Agents Cloud VM + LLM
Scan time 25 min 4 min 8 min
CVE check 20 min 3.5 min 6 min
Changelog 15 min 2 min 1 min
PR creation 50 min (5 services) 4 min 5 min
Total wall‑clock 90 min 14 min 20 min
Cost $0 (but my time) $0.044 (tokens) $0.92 (VM + API)
Setup effort None A couple hours A week of DevOps

Cost Comparison Per Audit Run

  • Manual: $0 (but 90 min @ $60/hr = $90 value)
  • Antigravity: $0.044 (14 min human oversight)
  • Cloud VM: $0.92 (20 min + week of DevOps setup)

Winner: Antigravity saves 76 min and $89.96 in labor value per run.

The cloud VM alternative was a $0.04/hr e2‑micro instance running a Python script that called the Gemini API with the same prompts, plus a GitHub CLI container. The token cost was higher because it had to re‑read the repo for each stage instead of reusing state in a sandbox.

🔍 Spot‑check: I manually verified the first five dependencies the Scanner Agent flagged against the npm and Go registries. The agent’s output was correct on 4 out of 5 — the one discrepancy was a patch version off‑by‑one, which the Verifier Agent later caught.

🛠️ Try This Yourself: Minimal Two‑Agent Example

Below is a 20‑line Python script you can run right now. It creates a Scanner Agent that audits a single directory and a Verifier Agent that cross‑checks the version of every package found against the public registry.

from google import genai
import json

client = genai.Client(api_key="YOUR_API_KEY")

# Create a managed sandbox
interaction = client.interactions.create(
    agent="antigravity-preview-05-2026",
    config={
        "tools": ["code_execution", "web_browsing", "file_management"],
        "sandbox": "isolated_linux"
    }
)

# Note: The API structure shown is simplified for clarity.
# Check documentation for the latest SDK reference.

# Scanner Agent: generate dependency report
scan_task = """Scan /workspace for package.json and requirements.txt.
Output a JSON list of {package, current_version, latest_version} to /workspace/deps.json."""

client.interactions.send_message(interaction_id=interaction.id, message=scan_task)

# Verifier Agent: cross-check against registry
verify_task = """Read /workspace/deps.json.
For each package, curl the public registry and confirm the latest version.
Output a corrected /workspace/verified_deps.json."""

client.interactions.send_message(interaction_id=interaction.id, message=verify_task)

print("Done. Check verified_deps.json in the sandbox.")

Setup Snapshot

  • Cost: ~$0.02 for a test run
  • Time: 3 minutes to set up, 2 minutes to execute
  • What you’ll learn: Whether your dependencies are genuinely up‑to‑date and whether the agent hallucinates any version numbers.

Production Readiness Checklist: 6 Things You Must Handle

1. Endless Reasoning Loops

  • Problem: The agent’s stop condition is “the model decides it’s done.” Tell it to “check all package files recursively” and it may happily circle through node_modules forever.
  • Solution: Build a wrapper that counts tool calls and force‑stops after 20.
  • Why it matters: Without a ceiling, a single rogue prompt can burn dollars in minutes.

2. Sandbox Filesystem Consistency

  • Problem: After writing a large JSON, the next read sometimes returns a stale version—duplicate entries, missing data.
  • Solution: Explicitly run sync via the shell tool before every read.
  • Why it matters: Stale state corrupts downstream agents and erodes trust in the entire pipeline.

3. Cost Unpredictability

  • Problem: The $0.37 figure was my entire weekend; a clean run costs fractions of a cent. But one agent got stuck in a recursive retry loop parsing a malformed package.json and ate $0.89 in seconds.
  • Solution: At the time of testing, I couldn’t find a native cost‑cap feature in the API. Wrap your API calls with a token budget tracker that raises an exception if limits are exceeded.
  • Why it matters: Production pipelines need spend guarantees, not gambling.

4. Hallucinations in Dependency Versioning

  • Problem: Gemini 3.5 Flash confidently reported [email protected] (unreleased) and mis‑identified a Go module’s minor bump as a breaking change.
  • Solution: Always run a verifier agent that hits the actual package registry via curl. I caught 3 out of 4 hallucinations this way.
  • Why it matters: A hallucinated CVE or version can lead to unnecessary rollbacks or missed patches.

5. Credential Scoping Is All‑or‑Nothing

  • Problem: Secrets are scoped to the entire interaction, not per‑agent. My Scanner Agent technically had the same GITHUB_TOKEN as the PR Agent, violating least‑privilege.
  • Solution: Until Google supports per‑agent secrets, use separate interactions for read‑only and write‑enabled stages.
  • Why it matters: A compromised scanner shouldn’t be able to open PRs.

6. Debugging Opacity

  • Problem: No streaming log of agent tool calls. You either stare at a blank terminal or wait 8 minutes for the web dashboard replay.
  • Solution: After every stage, call client.interactions.get() and assert state == “COMPLETED” and output is non‑empty.
  • Why it matters: Silent failures waste time and token budgets.

Why This Matters Beyond Dependency Audits

Google didn’t demo 93 agents to sell you a dependency auditor. They signaled a platform bet: that the unit of compute is shifting from “a single model call” to “a managed runtime where agents persist, schedule themselves, and collaborate.”

This is Google’s answer to LangChain and AutoGen — hosted, vertically integrated with TPU‑optimized models, and bundled into Gemini’s pricing. If OpenAI releases comparable managed agents soon, the pricing war will be brutal, and developers will benefit.

My dependency audit is a trivial example. What becomes possible when you can spin up a verifier agent for every PR, or a security‑scanning agent that watches your monorepo continuously and opens fixes before you even wake up? That’s the real headline from I/O 2026, and it’s a shift that will matter long after the smart glasses stop making news.

What I’m Building Next

1. Automated Security Monitoring (Try This Version)

Here’s the daily CVE scanner I’m deploying next week. It runs the Security Agent every morning, diffs the report against yesterday’s, and opens PRs only for new critical findings.

import subprocess
import json
import datetime

today = datetime.date.today().isoformat()
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()

# Run the Security Agent (same sandbox, same report path)
subprocess.run(["python", "security_agent.py", f"--date={today}"])

# Diff the CVE reports
with open(f"report_{yesterday}.json") as f: 
    old_report = json.load(f)
with open(f"report_{today}.json") as f: 
    new_report = json.load(f)

new_critical = [
    cve for cve in new_report.get("vulnerabilities", [])
    if cve["severity"] == "CRITICAL"
    and cve not in old_report.get("vulnerabilities", [])
]

# Open PRs for new critical CVEs
for cve in new_critical:
    subprocess.run([
        "gh", "pr", "create",
        "--title", f"CRITICAL: {cve['package']} {cve['id']}",
        "--body", f"Automated PR. CVE details:n{cve['description']}nnFixed in {cve['fixed_version']}",
        "--assignee", "@security-team"
    ])
    print(f"Opened PR for {cve['id']}")

print(f"Checked {len(new_report['vulnerabilities'])} CVEs, {len(new_critical)} new critical.")

  • Cost: ~$0.15/day for 14 services
  • Setup: 10 minutes to add your own report paths
  • Result: Zero‑day vulnerabilities caught within hours instead of weeks.

2. Cost‑Aware Orchestration Wrapper

A thin Python wrapper that enforces token budgets per agent and sends Slack alerts when a pipeline approaches the ceiling. Essential for production peace of mind.

The Bottom Line

The surprising part wasn’t that the agents made mistakes. Junior engineers make mistakes too. The surprising part was how quickly the system became genuinely useful once I stopped expecting perfection and started treating it like a collaborative runtime.

Double‑check its work and you’ll be thrilled. Trust it blindly and you’ll get burned.

Try the 20‑line example above. It takes 3 minutes, costs a couple of cents. Then you’ll see firsthand that managed agents aren’t replacing developers yet — they’re replacing the parts of the job developers hate most.

If you’re exploring the I/O announcements, don’t just watch the demos — take one and push it against your own real‑world workflow. That’s where the actual lesson lives.

📝 AI Transparency & Methodology Declaration

  • Empirical Data & Testing: All performance metrics, token tallies, dollar figures ($0.37 total weekend spend, $0.044 per target run), and technical edge cases detailed in this article are based on real, hands-on development hours logged between May 20–21, 2026. Testing was executed on a private 14-service monorepo containing Node.js, Python, Go, and Rust components, utilizing the antigravity-preview-05-2026 pipeline.
  • AI Assistance Disclosure: I used AI as a coding partner to help scaffold the Python boilerplate, draw the token-flow diagram, and clean up the markdown. Every bug, architectural decision, and conclusion in this piece is from my own testing and engineering judgment.
  • I have spent over four years building and maintaining production microservices, including debugging numerous dependency-related incidents—which is why solving this workflow pipeline matters.

If this helped you, I’d love to hear about it. Share what you built with Antigravity agents in the comment section.

Building Real-Time Apps in Node.js with Rivalis: WebSockets, Rooms, Actors, and a Binary Wire

If you have ever tried to build a multiplayer game, a collaborative editor, or a chat server in Node.js, you have probably written the same boilerplate twice: a ws server, a map of socket → user, a map of room → users, an auth handshake, a heartbeat, a rate limiter, a reconnect loop on the client, and a bespoke binary or JSON protocol gluing it all together.

Rivalis is the framework I wish I had the first time I wrote that boilerplate. It splits into two packages:

  • @rivalis/core — the Node.js server. Rooms, actors, auth middleware, pluggable transports, and a binary wire format.
  • @rivalis/browser — a small typed browser client with reconnect, token-refresh, and Sec-WebSocket-Protocol ticket delivery.

Both are MIT licensed, TypeScript-first, and at v6 on npm. Let’s walk through the design and then build a small example.

A small note before we dive in

I want to be honest about what this project is, because I think it matters.

Rivalis is my pet project. I have been working on it since 2022, on and off, in evenings and weekends and the quiet stretches between deadlines. It started because I kept making the same mistakes every time I built a multiplayer backend — the same race conditions, the same auth handshake bugs, the same “why is this socket leaking memory at 3 AM” moments. After the third or fourth time, I realized the lesson was not “be more careful next time.” The lesson was “stop rewriting this from scratch.”

So the goal of Rivalis is simple: take everything I have learned about building real-time multiplayer servers — the close-code conventions, the timing-safe comparisons, the heartbeat tuning, the way you should never trust the client’s clock, the difference between rate limiting at the socket layer and at the room layer — and bundle it into something other people can use without paying the same tuition I did. If it saves even one person a weekend of debugging, that is the project earning its keep.

It is also not just a hobby anymore. The framework has been quietly battle-tested in a handful of companies over the past couple of years, running multiplayer game servers and real-time collaboration backends in production, and the results have been honestly better than I expected. Long-uptime, low-incident, the kind of boring stability you actually want from a real-time layer. That feedback loop — seeing it survive contact with real users — is what pushed me to keep polishing the API and finally write this article.

If you find rough edges, please tell me. The whole point is that you should not have to learn them the hard way.

The mental model

Rivalis gives you four primitives:

Primitive What it is
Rivalis The server entrypoint. Holds your transports, auth, rate limiter, and room manager.
Room A named space you subclass. Binds inbound topics to handlers; broadcasts to its members.
Actor A per-connection handle inside a room. Carries data returned by auth, plus id, send, kick.
AuthMiddleware One method — authenticate(ticket) — returns { data, roomId } or null.

The data flow on every connection:

client.connect(ticket)
        │
        ▼
  AuthMiddleware.authenticate(ticket)
        │
   ┌────┴────┐
   │         │
  null    { data, roomId }
   │         │
  close   join room → Actor
4001       (onJoin fires)

That is the whole shape. Everything else — heartbeats, rate limiting, presence, reconnects — is configured, not coded.

Hello world: an echo chat server

The server, in full:

import http from 'http'
import {
    Rivalis, Transports, Room, AuthMiddleware,
    type AuthResult, type Actor
} from '@rivalis/core'

type ActorData = { name: string }

class ChatRoom extends Room<ActorData> {
    protected override presence = true   // auto __presence:join / __presence:leave

    protected override onCreate() {
        this.bind('chat', this.onChat)
    }

    protected override onJoin(actor: Actor<ActorData>) {
        actor.send('welcome', JSON.stringify({ youAre: actor.data?.name ?? '' }))
    }

    private onChat(actor: Actor<ActorData>, payload: Uint8Array) {
        this.broadcast('chat', payload)
    }
}

class Auth extends AuthMiddleware<ActorData> {
    override async authenticate(ticket: string): Promise<AuthResult<ActorData> | null> {
        const name = ticket.trim()
        if (!name || name.length > 20) return null
        return { data: { name }, roomId: 'global' }
    }
}

const server = http.createServer()
const rivalis = new Rivalis<ActorData>({
    transports: [new Transports.WSTransport({ server })],
    authMiddleware: new Auth()
})
rivalis.rooms.define('chat', ChatRoom)
rivalis.rooms.create('chat', 'global')
server.listen(8080, () => console.log('ws://localhost:8080'))

process.on('SIGINT', async () => { await rivalis.shutdown(); process.exit(0) })

The browser side is just as small:

import { WSClient } from '@rivalis/browser'

const ws = new WSClient<'chat' | 'welcome'>('ws://localhost:8080')
const enc = new TextEncoder()
const dec = new TextDecoder()

ws.on('client:connect', () => console.log('connected'))
ws.on('welcome', (p) => console.log('welcome:', dec.decode(p)))
ws.on('chat', (p) => console.log('chat:', dec.decode(p)))

ws.connect('alice')
ws.send('chat', enc.encode('hello world'))

That is a complete real-time chat. No express, no socket.io, no Redis. Run it.

Rooms are where the logic lives

A Room is a class you subclass. It has lifecycle hooks (onCreate, onJoin, onLeave, onDestroy) and a small API surface:

Method Purpose
bind(topic, listener) Register inbound topic handler.
bindAny(listener) Wildcard fallback for unbound topics.
send(actor, topic, payload) Unicast.
broadcast(topic, payload) Fan-out.
each(fn) Iterate room actors.
kick(actor, payload?) Disconnect with a reason.
destroy() Tear down the room.

A few details worth knowing:

  • maxActors and joinable are simple instance fields. Set maxActors = 4 and the fifth joiner gets closed with room_full.
  • presence = true auto-broadcasts __presence:join and __presence:leave with a JSON payload describing the joining/leaving actor. You can override presencePayload(actor) to scrub server-only fields out before the broadcast.
  • Reserved topics. The __ prefix is reserved for framework events. bind/unbind throw if you try to claim one.
  • unknownTopicPolicy defaults to 'kick' — an actor sending a topic nobody bound is treated as misbehaving. Switch to 'drop' for permissive apps.

The Room API is deliberately small. Game state, lobbies, turn logic — that is all just code you write inside your subclass.

Actors carry your auth payload

Whatever your AuthMiddleware.authenticate returns in data is stamped on the Actor for the lifetime of the connection. That means after auth, the room never needs to look up “who is this user” — the data is already on the actor:

class JWTAuth extends AuthMiddleware<{ userId: string; tier: 'free' | 'pro' }> {
    override async authenticate(ticket: string) {
        const claims = await verifyJwt(ticket)
        if (!claims) return null
        return {
            data: { userId: claims.sub, tier: claims.tier },
            roomId: claims.room
        }
    }
}

Inside the room:

private onChat(actor: Actor<{ userId: string; tier: 'free' | 'pro' }>, payload: Uint8Array) {
    if (actor.data?.tier !== 'pro' && payload.byteLength > 256) return
    this.broadcast('chat', payload)
}

Actor also has save<T>(key, value) and get<T>(key) for per-actor scratch state — handy for in-room counters, last-seen timestamps, anti-spam buckets.

One security note from the docs that is worth repeating. Any secret comparison inside authenticate — HMACs, signatures, session tokens — must use crypto.timingSafeEqual or an equivalent constant-time comparator. === and Buffer.compare short-circuit on first mismatch and leak the prefix length under enough samples. The framework cannot enforce this for you; if you compare a signature with ===, you have built a timing oracle.

The wire is binary, but opaque

Every frame on the wire is { topic: string, payload: bytes }. The framework does not inspect payload. You encode it however you want — JSON, protobuf, msgpack, raw bytes, a hand-rolled struct.

For a chat app, JSON over TextEncoder is fine:

const enc = new TextEncoder()
const dec = new TextDecoder()

export const encode = <T>(v: T): Uint8Array => enc.encode(JSON.stringify(v))
export const decode = <T>(p: Uint8Array): T => JSON.parse(dec.decode(p)) as T

For a 60 Hz multiplayer game, swap in @bufbuild/protobuf or msgpackr. The wire layer does not care. It does enforce one thing: frames are binary. Send a text frame and the server closes the connection with INVALID_FRAME (4002).

Security defaults that exist on purpose

A lot of WebSocket tutorials end at “and now you have real-time!” without ever mentioning that you have also just opened a fresh attack surface. Rivalis ships defaults for the boring stuff:

  • Token-bucket rate limiting, on by default — 30 tokens, 30/sec refill. Subclass RateLimiter to swap it out, pass null to opt out.
  • Heartbeats — 30-second ping with a 2-miss kick. Configurable on WSTransport.
  • Frame and topic size caps — maxPayload (64 KiB default) and maxTopicLength (256 default). Anything bigger gets dropped at the transport.
  • Backpressure handling — a per-socket outbound buffer cap (1 MiB default); the onBackpressureDrop hook fires on every dropped frame so you can escalate (e.g. kick the slow actor).
  • Origin allow-list — pass allowedOrigins to WSTransport and any other Origin header is rejected before auth runs. Required for CSWSH protection if you ever decide tickets ride on cookies.
  • Pre-handshake connection limiting — subclass ConnectionLimiter to cap how many sockets a single IP can open per second. Rejected with RATE_LIMITED (4005) before any auth code runs.
  • Ticket via subprotocol — set ticketSource: 'protocol' on both the server and browser client, and the ticket rides on Sec-WebSocket-Protocol instead of ?ticket=. Keeps credentials out of URL access logs and browser history.
  • Tickets are never logged in plaintext — only an 8-char SHA-256 fingerprint, so a leaked log file does not become a session-takeover.

None of those need code from you on the happy path. The defaults are sane; the knobs exist when you need them.

The browser client is small on purpose

@rivalis/browser has five methods: connect, disconnect, send, on/once/off. Everything else is a configuration choice.

Typed events

The client:* events have typed payloads, and you can constrain user topics through a generic:

type Topics = 'lobby:state' | 'chat' | 'game:tick'

const ws = new WSClient<Topics>('wss://example.com/ws')

ws.on('chat', (p) => { /* p: Uint8Array */ })
ws.on('typo:state', (p) => { ... })            // type error
ws.on('client:kicked', ({ code, reason }) => { ... })

Reconnection done right

const ws = new WSClient(url, {
    reconnect: { maxAttempts: 8, baseDelayMs: 250, maxDelayMs: 5000 }
})

Exponential backoff with jitter. Importantly, reconnect skips terminal close codes. If the server kicked you with INVALID_TICKET, KICKED, or ROOM_REJECTED, the client treats that as a final answer and does not retry. Those mean “the server does not want you back” — silently looping is just noise.

Refreshing short-lived tickets

If your tickets are short-lived JWTs, the getTicket hook is called before every reconnect attempt:

const ws = new WSClient(url, {
    reconnect: true,
    getTicket: async () => {
        const res = await fetch('/api/realtime-token', { credentials: 'include' })
        if (!res.ok) throw new Error('token endpoint failed')
        return await res.text()
    }
})

ws.connect(initialTicket)   // first call still uses its argument verbatim

If getTicket throws, the loop terminates with client:reconnect_failed. You cannot reconnect without a ticket, so there is nowhere to retry to.

Close codes you will actually see

Knowing the close codes is the difference between a useful error UI and “connection lost”:

Code Meaning
4001 INVALID_TICKET — bad/missing ticket; auth rejected
4002 INVALID_FRAME — non-binary frame received
4003 KICKED — server-initiated kick (reason in payload)
4004 ROOM_REJECTED — room_full or room_not_joinable
4005 RATE_LIMITED — pre-handshake connection limiter

The browser client emits client:kicked with { code, reason } parsed for you, so you can route each case to the right UI without peeking into the close payload yourself.

Graceful shutdown

A real deployment needs a clean SIGTERM path. rivalis.shutdown() destroys every room (firing onDestroy and kicking remaining actors with room_destroyed), then disposes every transport (closing live sockets with KICKED + 'server_shutdown'):

process.on('SIGINT',  async () => { await rivalis.shutdown({ timeoutMs: 5000 }); process.exit(0) })
process.on('SIGTERM', async () => { await rivalis.shutdown({ timeoutMs: 5000 }); process.exit(0) })

The timeoutMs is the upper bound for transport disposal. Sockets that have not closed by then get torn down.

When to reach for Rivalis

It is a good fit for:

  • Multiplayer games — turn-based or real-time, where you want a GameRoom subclass holding authoritative state and broadcasting deltas.
  • Chat / presence / collaborative cursors — presence: true and a couple of bound topics get you most of the way.
  • Collaborative editors — the binary-frame primitive plays well with CRDT payloads. Just plumb Yjs or Automerge updates into a topic.
  • Live dashboards with per-tenant rooms — roomId from your auth payload routes each tenant to their own room.

It is not a queue, an SFU, or a Pub/Sub between Node instances. If you need horizontal fan-out, you compose it — one Redis stream feeding many Rivalis nodes, each broadcasting to its locally-joined actors. The framework deliberately stays inside one process; that is what makes the room/actor model cheap.

A 60-second tasting menu of the surface area

// Server
const rivalis = new Rivalis({ transports: [...], authMiddleware: new MyAuth() })
rivalis.rooms.define('chat', ChatRoom)
rivalis.rooms.create('chat', 'global')
rivalis.connections                       // joined actors
rivalis.sockets                           // open sockets (incl. pre-handshake)
rivalis.logging.level = 'debug'           // built-in logger
await rivalis.shutdown({ timeoutMs: 5000 })

// Room
class GameRoom extends Room<MyData> {
    override maxActors = 4
    override joinable = true
    protected override presence = true
    protected override unknownTopicPolicy = 'drop'
    protected override onCreate() { this.bind('move', this.onMove) }
    protected override onJoin(actor) { actor.send('state', JSON.stringify(state)) }
    protected override onLeave(actor) { /* cleanup */ }
    private onMove(actor, payload) { this.broadcast('state', nextState) }
}

// Browser
const ws = new WSClient<MyTopics>(url, {
    reconnect: { maxAttempts: 8 },
    ticketSource: 'protocol',
    getTicket: refreshJwt
})
ws.on('client:connect', ...)
ws.on('client:kicked', ({ code, reason }) => ...)
ws.connect(jwt)
ws.send('move', encode({ dx: 1 }))

Install and play

npm install @rivalis/core ws @toolcase/base @toolcase/logging @toolcase/serializer
npm install @rivalis/browser

Both packages declare their @toolcase/* and ws dependencies as peers, so you control the versions in your app.

If you build something on top of it — a game, a tutorial, a CRDT bridge — please open an issue or a PR. Feedback on the security defaults and the binary-frame design is especially welcome.

Happy broadcasting.