Cisco ACI vs VMware NSX in 2026: Architecture, Microsegmentation, and Automation Compared

Cisco ACI and VMware NSX are the two dominant data center SDN platforms, but they solve fundamentally different problems. ACI is a hardware-integrated fabric that manages both physical and virtual infrastructure through an application-centric policy model. NSX is a hypervisor-based overlay that virtualizes networking entirely in software. In 2026, the landscape has shifted dramatically — Broadcom’s acquisition of VMware has disrupted NSX licensing, while ACI continues to deepen its VXLAN EVPN integration.

TL;DR: ACI and NSX aren’t really competitors — they operate at different layers and many enterprises run both. But the Broadcom pricing shakeup is pushing organizations to lean harder on their Cisco fabric investment, making ACI architecture skills more valuable than ever.

Architecture: Two Fundamentally Different Approaches

The simplest way to understand the difference: NSX virtualizes the network from the hypervisor up. ACI builds the network from the hardware down.

Cisco ACI Architecture

                    ┌─────────────┐
                    │    APIC     │  ← Centralized policy controller
                    │  (Cluster)  │     Defines tenants, EPGs, contracts
                    └──────┬──────┘
                           │ OpFlex
              ┌────────────┼────────────┐
        ┌─────┴─────┐           ┌─────┴─────┐
        │   Spine   │           │   Spine   │  ← VXLAN EVPN fabric
        │ (N9K-9500)│           │ (N9K-9500)│
        └─────┬─────┘           └─────┬─────┘
         ┌────┼────┐             ┌────┼────┐
    ┌────┴──┐ ┌┴────┐      ┌────┴──┐ ┌┴────┐
    │ Leaf  │ │Leaf  │      │ Leaf  │ │Leaf  │  ← Policy enforcement
    │(N9K)  │ │(N9K) │      │(N9K)  │ │(N9K) │     at the switch port
    └───┬───┘ └──┬───┘      └───┬───┘ └──┬───┘
        │        │              │        │
    [Servers] [VMs]        [Servers] [Bare Metal]

Key ACI concepts:

  • APIC — the brain. 3-node cluster defining all policy.
  • Tenants — logical isolation containers (like VRFs on steroids)
  • EPGs (Endpoint Groups) — security zones. Endpoints classified by VLAN, IP, or VMM integration
  • Contracts — rules governing which EPGs can communicate. Default: deny all between EPGs
  • OpFlex — protocol between APIC and leaf switches for policy distribution

ACI runs on Nexus 9000 hardware in ACI mode. The fabric is VXLAN EVPN spine-leaf where APIC overlays its policy model. Physical servers, VMs, containers, and bare-metal nodes all live under the same policy framework.

VMware NSX Architecture

    ┌─────────────────────────────────────────┐
    │           NSX Manager (Cluster)          │  ← Management + control plane
    └──────────────────┬──────────────────────┘
                       │
    ┌──────────────────┼──────────────────────┐
    │     Transport Zone (Overlay Network)     │
    │                                          │
    │  ┌──────────┐  ┌──────────┐  ┌────────┐│
    │  │ESXi Host │  │ESXi Host │  │ESXi    ││
    │  │┌────────┐│  │┌────────┐│  │┌──────┐││
    │  ││N-VDS   ││  ││N-VDS   ││  ││N-VDS │││  ← Distributed virtual switch
    │  ││┌──┐┌──┐││  ││┌──┐┌──┐││  ││┌──┐  │││
    │  │││VM││VM│││  │││VM││VM│││  │││VM│  │││
    │  ││└──┘└──┘││  ││└──┘└──┘││  ││└──┘  │││
    │  ││  DFW   ││  ││  DFW   ││  ││ DFW  │││  ← Distributed Firewall
    │  │└────────┘│  │└────────┘│  │└──────┘││     in kernel
    │  └──────────┘  └──────────┘  └────────┘│
    └─────────────────────────────────────────┘
         │               │              │
    ┌────┴───────────────┴──────────────┴────┐
    │      Any Physical Network Underlay      │  ← Hardware-agnostic
    │    (Cisco, Arista, Juniper, anything)    │
    └────────────────────────────────────────┘

Key NSX concepts:

  • NSX Manager — centralized management/control plane (3-node cluster)
  • Segments — L2 overlay networks (GENEVE encapsulation, not VXLAN)
  • Distributed Firewall (DFW) — stateful firewall in the hypervisor kernel at every VM’s vNIC
  • Tier-0/Tier-1 Gateways — distributed routing between segments

NSX runs entirely in software on the hypervisor. The physical underlay can be anything.

Head-to-Head Comparison

Category Cisco ACI VMware NSX
Deployment model Hardware + software (Nexus 9000 required) Software-only (any underlay)
Controller APIC (3-node cluster) NSX Manager (3-node cluster)
Encapsulation VXLAN GENEVE
Policy scope Physical + virtual + container + bare-metal Virtual workloads (VMs + containers)
Microsegmentation EPG/ESG contracts at fabric level Distributed Firewall at hypervisor kernel
Multi-site ACI Multi-Site with VXLAN EVPN BGW NSX Federation
Automation API REST API + Terraform + Ansible + Python SDK REST API + Terraform + Ansible + PowerCLI
Hypervisor support VMware, Hyper-V, KVM, bare-metal VMware vSphere (primary), KVM (limited)
Hardware lock-in Yes (Nexus 9000 only) No (any physical underlay)
Licensing model (2026) Perpetual + subscription options Subscription-only (Broadcom bundles)

Microsegmentation: Different Layers, Different Strengths

This is the most debated topic in ACI vs NSX discussions. Both platforms offer microsegmentation, but they enforce it at different layers.

ACI: Fabric-Level Enforcement

ACI enforces policy at the leaf switch TCAM using Endpoint Security Groups (ESGs) or traditional EPG contracts:

Tenant: Production
  ├── VRF: Prod-VRF
  ├── App Profile: ERP-App
  │   ├── EPG: Web-Tier     (VLAN 100)
  │   ├── EPG: App-Tier     (VLAN 200)
  │   └── EPG: DB-Tier      (VLAN 300)
  └── Contracts:
      ├── Web-to-App: permit HTTPS (tcp/443)
      ├── App-to-DB: permit SQL (tcp/1433)
      └── Web-to-DB: implicit deny (no contract)

ACI’s strength: physical and virtual endpoints under the same policy.

NSX: Hypervisor-Level Enforcement

NSX’s DFW runs in the ESXi kernel, inspecting every packet at the VM’s virtual NIC:

NSX Security Policy:
  Group: Web-Servers (tag: role=web)
    ├── Allow: HTTPS from Any
    ├── Allow: SSH from Jump-Box group
    └── Deny: All other inbound

  Group: DB-Servers (tag: role=database)
    ├── Allow: SQL from App-Servers group only
    └── Deny: All other

NSX’s strength: VM-granular enforcement without touching the physical network.

When Each Wins

Scenario Winner Why
VM-to-VM security within vSphere NSX DFW operates at kernel, follows vMotion
Mixed physical + virtual policy ACI Unified policy across all endpoint types
Zero-trust within a single hypervisor cluster NSX Granular per-vNIC enforcement
Multi-vendor DC fabric security NSX Hardware-agnostic overlay
Cisco-only shop with bare-metal + VMs ACI Single policy domain for everything
Running both together Both ACI underlay + NSX overlay is officially supported

Many enterprises run both. Cisco publishes an official design guide for deploying NSX-T on ACI fabric.

The 2026 Elephant: Broadcom’s VMware Acquisition

The biggest change to this comparison in 2026 isn’t technical — it’s financial.

Broadcom completed its $69B acquisition of VMware in November 2023, and by 2026 the licensing landscape is thoroughly disrupted:

  • Perpetual licenses eliminated — all VMware products moved to subscription-only
  • Product bundling enforced — NSX is part of VMware Cloud Foundation (VCF), not available standalone for new customers
  • Minimum core requirements — 72-core licensing minimum per site
  • Price increases of 2–10x reported by many customers

Real consequences:

  1. Some enterprises are deepening ACI investment instead of renewing NSX
  2. Others are exploring open-source alternatives like OVN/OVS
  3. Hybrid environments persist but budget pressure makes “both” harder to justify
  4. ACI expertise becomes more valuable as organizations reducing NSX scope need stronger ACI policy design

Automation and API Comparison

Both platforms offer robust automation.

ACI Automation (Python SDK)

# ACI Python SDK (Cobra) — create an EPG
from cobra.mit.access import MoDirectory
from cobra.mit.session import LoginSession
from cobra.model.fv import AEPg, RsBd

session = LoginSession("https://apic.lab.local", "admin", "password")
moDir = MoDirectory(session)
moDir.login()

tenantDn = "uni/tn-Production/ap-ERP-App"
epg = AEPg(tenantDn, name="New-Web-Tier")
rsBd = RsBd(epg, tnFvBDName="Web-BD")
moDir.commit(epg)

ACI also supports: Terraform (cisco/aci), Ansible (cisco.aci), REST API, and Cloud Network Controller for AWS/Azure.

NSX Automation (REST API)

# NSX-T REST API — create a segment
import requests

url = "https://nsx-manager.lab.local/policy/api/v1/infra/segments/web-segment"
payload = {
    "display_name": "Web-Segment",
    "subnets": [{"gateway_address": "10.10.10.1/24"}],
    "transport_zone_path": "/infra/sites/default/enforcement-points/default/transport-zones/overlay-tz"
}

response = requests.put(url, json=payload,
                       auth=("admin", "VMware1!"), verify=False)

NSX supports Terraform (vmware/nsxt), Ansible, and PowerCLI.

Market Reality: Where the Jobs Are (2026)

Skill % of DC Engineer Listings Salary Premium
Cisco ACI 65% +15% over base DC salary
VXLAN EVPN 55% +12%
VMware NSX 35% +8%
Both ACI + NSX 20% +22%
Terraform/Ansible for DC 40% +18%

ACI appears in nearly twice as many job listings as NSX. But engineers who know both command the highest premium — a 22% salary bump.

The VXLAN market is projected to grow from $1.6B (2024) to $3.2B by 2029 at 15% CAGR. AI workloads are the primary driver — every new GPU cluster needs VXLAN EVPN fabric.

The Bottom Line

If you are… Focus on… Why
Working DC engineer Both Real-world environments often run both
Career switcher into DC ACI first More job listings, higher premium
Security-focused NSX DFW + ACI ESG Microsegmentation is everywhere
Automation-focused ACI APIs + Terraform Fastest path to high-paying DC roles

FAQ

What is the main difference between Cisco ACI and VMware NSX?

ACI is hardware-integrated SDN (Nexus 9000 + APIC) managing physical + virtual workloads. NSX is hypervisor-based, hardware-agnostic, running entirely in software.

Can they run together?

Yes. ACI provides the physical fabric underlay; NSX handles hypervisor-level microsegmentation. Cisco publishes an official design guide.

Which is better for microsegmentation?

Different layers. NSX DFW runs in the hypervisor kernel (VM-granular, follows vMotion). ACI EPG/ESG enforces at the fabric switch level (physical + virtual). NSX wins pure VM-to-VM; ACI wins unified policy.

How has Broadcom affected NSX in 2026?

Perpetual licenses gone, NSX bundled into VCF subscription-only, 2–10x price increases reported. Driving enterprises to deepen ACI investment or explore open-source alternatives.

Originally published at firstpasslab.com. For more data center networking deep dives, check us out there.

🤖 AI Disclosure: This article was adapted from the original with AI assistance. All technical content, architecture comparisons, and market data were researched and verified by the FirstPassLab engineering team.

CSS Box Model Visualizer, Meta Tag Checker, Regex Highlighter — 3 New Tools

3 new interactive tools:

CSS Box Model Visualizer

Interactive margin, padding, border, and content visualization. Adjust sliders, see the box model update live. Copy CSS.

Box Model

Meta Tag Length Checker

Preview how your page looks in Google. Title (30-60 chars) and description (120-160 chars) with progress bars and SERP preview.

Meta Tag Checker

Regex Visualizer

See regex matches highlighted in real-time in your text. Click matches to copy. Shows match count.

Regex Visualizer

Full toolkit (468 tools): devtools-site-delta.vercel.app

Experimental Web Install API Seeks to Improve Application Discovery and Distribution

The new, experimental Web Install API is now in Origin Trial in Microsoft Edge and Chrome. The API allows developers to programmatically trigger a PWA installation prompt from in-app user interactions. The API aims to simplify software discovery and distribution, particularly for users who are unaware of the install icon in the browser’s address bar or do not typically use app stores.

By Bruno Couriol

I shipped Google’s TurboQuant as a vLLM plugin 72 hours after the paper — here’s what nobody else tested

Google published TurboQuant at ICLR 2026 — a technique that compresses transformer KV caches to 4 bits per coordinate with zero accuracy loss. The paper reports 5-6x memory reduction on H100 GPUs, tested on text models like Gemma and Mistral.

I wanted to know: does it work on a vision-language model processing video? On a consumer GPU?

72 hours later, turboquant-vllm is on PyPI.

Quick Start

pip install turboquant-vllm[vllm]
vllm serve allenai/Molmo2-8B --attention-backend CUSTOM

That’s it. The plugin auto-registers via vLLM’s entry point system. No code changes, no forking, no monkey-patching.

For HuggingFace users:

from transformers import DynamicCache
from turboquant_vllm import CompressedDynamicCache

cache = DynamicCache()
compressed = CompressedDynamicCache(cache, head_dim=128, bits=4)
# Pass cache (not wrapper) to model.generate()

Why Vision-Language Models Matter

Every other TurboQuant implementation tests on text-only models with hundreds of tokens. But a 12-second video clip through Molmo2-4B produces ~11,000 visual tokens — 1.6 GB of KV cache on a 24 GB GPU.

That’s 10x more memory, 10x more opportunities for precision bugs to compound across 36 transformer layers. The existing VLM compression literature (VL-Cache, Dynamic-LLaVA, ZipVL) is all token pruning — deciding which tokens to discard. TurboQuant compresses the tokens you keep. They’re complementary approaches, and nobody had validated whether vector quantization survives the visual token regime.

It does.

Results

Molmo2-4B on RTX 4090, 11K visual tokens from a Seinfeld video clip:

Metric Baseline TQ4 Compressed
KV cache 1,639 MiB 435 MiB (3.76x)
Output quality Detailed scene description Near-identical (100+ tokens match)
Decode overhead — 1.78x

Molmo2-8B: same 3.76x ratio, correctly identifies all Seinfeld characters. Full 23-minute episode processed at 24 tok/s.

What I Built Differently

Plugin, not fork

Other vLLM TurboQuant efforts are forks or monkey-patches. turboquant-vllm uses vLLM’s official plugin entry point:

[project.entry-points."vllm.general_plugins"]
tq4_backend = "turboquant_vllm.vllm:register_tq4_backend"

Incremental dequantization

The naive approach decompresses the full KV cache at every layer, every step — 3.36x overhead. Incremental dequantization decompresses only the 1 new token per step and appends to a running buffer. Overhead drops to 1.78x. This isn’t in Google’s paper.

Cross-platform Triton

Fused kernels run on both NVIDIA CUDA and AMD ROCm without code changes. 84/84 GPU tests pass on a Radeon 890M iGPU.

Bugs Nobody Else Has Found

  1. FP16 norms fail at scale. Works at 11,385 tokens, garbles output at 11,397 tokens. The 0.01% error per vector compounds across 36 layers. Always use fp32.

  2. QJL correction is invisible in standard attention. The paper’s Stage 2 (2-bit MSE + 1-bit QJL) wastes 1 bit of precision — standard Q @ K^T can’t use the correction. Full 3-bit MSE produces identical output.

  3. Multi-layer precision drift in fused kernels. A 0.023 cosine gap per layer between fp32 Triton and bf16 SDPA compounds to produce “pizza pizza pizza” at 36 layers. Flash Attention-style fusion needed.

Validation

  • 180+ tests, 9 test files, 95%+ coverage
  • 16 GPU experiments with documented failures
  • Cross-platform: NVIDIA RTX 4090 + AMD Radeon 890M
  • End-to-end: installed from PyPI into stock vllm/vllm-openai:latest container

What’s Next

  • Upstream contribution to vLLM (issue #38171, 49 upvotes)
  • Full Flash Attention fusion for the fused Triton kernels
  • Stacking with VL-Cache-style token pruning for multiplicative VLM savings

PyPI | Docs | GitHub

From a Kitchen Debt to 100 Apps: How a Music Teacher With No CS Degree Built an Ecosystem That Changed Her Students’ Lives

This is a submission for the 2026 WeCoded Challenge: Echoes of Experience

Student names and some details have been changed for privacy reasons. The stories are real and documented.

The kitchen where it all started

In 1999 I was working as a music specialist in Italian elementary and preschools — a position even more precarious and underpaid than a regular substitute teacher. I composed musical fairy tales for children using a keyboard, but it was limited and clumsy. I had never touched a computer in my life.

My mother, a furniture dealer, sold a kitchen to the owner of a computer shop. He never paid. To settle the debt, she took a PC instead. I asked for music composition software too. He gave me the programs — but without serial numbers. Useless. I looked up the price of legitimate licenses. Completely out of reach.

I didn’t even know what Reverse Engineering was. But driven by necessity, I started studying how software worked under the hood. By pure chance I stumbled onto the UIC — the Italian University of Hacking and Cracking — a buzzing forum full of brilliant people and hundreds of tutorials. I learned x86 assembly and how to use debuggers like SoftICE and OllyDbg to understand program logic. I just wanted to make the tools I needed to compose music for my kids work, but I ended up learning how a computer “thinks.”

Then something unexpected happened: after learning low-level languages, I became curious about high-level ones. I had always been good at drawing and loved animation — so I taught myself ActionScript.

That’s how a music teacher with zero computer science education became a programmer. Not through a bootcamp. Not through a degree. Through a kitchen debt and sheer stubbornness.

The girl who could write but couldn’t read

Just a few years later, in the early 2000s, I got a position as a special needs support teacher and met a student who would change the direction of my life.

She was 13 years old and had suffered significant brain damage in early childhood. She had graceful handwriting and good manual skills, but she could only copy. She had never learned to read — she didn’t know the name of a single letter. Every traditional method had failed. Teachers, specialists, therapists — everyone had tried. Nothing worked.

I knew she loved Disney’s Finding Nemo. So I built her a game — animations, handcrafted text-to-speech, I recorded my own voice to compose letters and words, all in ActionScript. I called it Nemo’s Letters.

For the first time, after years of failed attempts by everyone, that girl started decoding written words. Within a few months she learned the letters and the basic principles of assembling words.

That moment taught me something I’ve carried for twenty years: technology built around a real need — not a market trend, not a curriculum requirement, but one specific child’s specific struggle — can change a trajectory that everyone else had given up on.

Twenty years of starting over

After that first success, I kept building. And the technology kept dying under me.

ActionScript was deprecated. I migrated to Visual Basic. Then VB was replaced by VB.NET. Along the way I studied HTML and CSS. I built the first versions of MemoApp — a study tool — and Parla — a text-to-speech reader for students with reading difficulties.

Every time I mastered something, it became obsolete. Every tool I built became harder and harder to install. It was deeply frustrating — but I kept going. A small voice inside told me it was right not to give up, to try every possible path, and that my students needed these tools.

The real breakthrough came about four years ago, with the arrival of artificial intelligence.

At the time I was teaching in a challenging school. Large classes and countless difficult cases. As a special needs teacher I was a true support for the entire class: I regularly worked with groups of five, six, seven students, managing the curriculum across all subjects. My software was a winning piece of the puzzle, but installation problems on school and home computers were a constant nightmare.

With AI as a programming assistant, I migrated everything to JavaScript. It was similar enough to ActionScript to make the transition natural. And finally — everything worked. No installations. Everything in the browser. Accessible from any device. My students could finally use the same tools at home that we used in class.

Thanks to AI and the skills I had already built over the years, in four years I went from a handful of desktop applications to an ecosystem of roughly one hundred web apps.

What I built — and why nothing else worked

I need to be specific about the problem. As a special needs teacher in an Italian middle school, I follow students across every subject. In a single week, a class covers dozens of topics in math, history, geography, science, English, Italian — and all of that material has to pass through me. I need to take it, adapt it, simplify it and present it in a way that each student can actually learn from. I’m often asked to do this almost instantly.

Textbooks don’t work for these students. They require reading levels and attention spans that my students don’t have yet. Commercial educational apps look polished but miss the point: they don’t offer real verification tools for teachers, they don’t adapt to individual needs, they completely lack flexibility and they don’t integrate with each other. Concept maps from the web and simplified materials found online — praised by a certain school of educators — in my experience failed miserably and didn’t meet real needs.

But the real problem runs deeper. It’s not just about which material you give these students — assuming you can even find or generate the right one. The problem is that these kids haven’t developed a study method. They read poorly, look at the images, but don’t stop to understand. Everything goes in and out. They don’t even realize they haven’t learned or understood anything.

I realized early on that these students needed something to guide them step by step. Something that would stop them when they made mistakes and put them back on track. That would make them think. That would lead them to learn through a path built specifically for them. There was nothing on the market that came even close to these needs. And my hours were almost always too few — we wrote this in the IEPs (Individualized Education Plans) — and often these students had no guidance at home either.

So I built my own tools.

The backbone: the MemoApp suite


View high-resolution image for technical and pedagogical details

MemoApp, born about fifteen years ago and slowly refined and adapted, might look like just another quiz app, but it’s not. It has multiple modes of use, each with different purposes, goals and details to meet specific needs:

  • Written quiz — the student answers primarily by writing. There’s also a mode for clicking options or making connections, but I’ve found that the writing mode — which forces repetition and active writing — produces the best results.

  • Voice loop — pressing play makes the app repeat the quiz in a continuous loop. You can choose different voices for questions and answers — essential for language learning, where one voice is in Italian and the other in the target language. You can adjust the pause between question and answer because the goal is active learning: during the pause, the student tries to guess the answer mentally, then immediately hears it.

  • Voice dialogue — the app asks questions and the student answers by speaking. It’s a real dialogue with the machine!

  • Quick navigation — you browse through the quiz using arrow keys, for rapid review.

  • Lesson mode — the teacher loads a quiz with images and uses it as the basis for a lesson (instead of a traditional presentation or Word document). Students see the question but not the answer: this pushes them to try to figure it out during the lesson, making learning active. At the end of the lesson, the teacher can scroll through the quiz asking surprise questions.

  • Test simulation — the student answers all questions and only at the end does the app show how many were wrong. It helps understand how much has truly been absorbed and how much work remains — before the real test.

Flexible options — at any time you can shuffle or reverse the order of questions, swap questions and answers, delete specific questions — usually the ones already mastered.

And then there’s the heart of the system: intelligent error management with smart regression. If a student gets it wrong, they don’t start over and they don’t move on ignoring the error. The app shows the correct answer, asks them to retype it, and uses a system of progressive blocks. It records the questions with the most errors and then drills specifically on those. This is not a detail — it’s what makes the difference between a game and a learning tool.

In other words, MemoApp is a tool that safely guides the student through learning. It offers active learning, imposes structured study rhythms, checks results at every step, corrects mistakes, forces students to learn specific terminology, to order events and dates. Images further reinforce comprehension and retention.

CreaSchede — the instant generator

If MemoApp is the teaching tool you use in class, CreaSchede is its natural and indispensable complement. It’s the tool that teachers — or even students themselves — use to quickly and easily create MemoApp flashcards.

It works simply. The user describes what they want in a chat — for example “create a quiz on the main stages of Hitler’s rise to power, use a discursive text format, dedicate the first cards to terminology” — and can also attach images or documents. In two minutes they have 40 flashcards with real images from the web, saved to Notion with a unique ID. The teacher can quickly browse the cards, edit text and questions, scroll through image galleries to refine the visuals, paste images from the web, or provide images that the app will upload and insert into the quiz. When saving, the AI also offers a pre-filled form with the card name and details to make future searching and cataloguing easier.

The student enters that code in MemoApp — or searches by name — and starts studying. Images are automatically searched from seven different sources through micimici MCP, a server I built myself that queries Pexels, Pixabay, Unsplash, Wikimedia, Europeana, Bing and OpenClipart. Real images — not AI-generated. This matters: it’s scientifically confirmed that content is better absorbed when paired with images, and for education you need real photographs.

PrintMemoApp — because not everyone has a device

A concrete problem: not all students have a PC or tablet at home. PrintMemoApp takes MemoApp flashcards — currently over 600, all searchable in a database — and prints them in various modes: with or without images, with a cover image, with answers (becoming a study text), without answers (becoming a test or assignment), with answers in random order at the top (becoming a matching exercise). PrintMemoApp can also quickly transform quizzes into tests to administer on the spot. One piece of material, five different uses.

PageCraft — the page and game generator

The latest addition and perhaps the most versatile. PageCraft creates web pages, presentations, posters, printable documents and interactive educational games — Memory, board games, quizzes, runner games — from a simple chat description or directly from MemoApp flashcards. It searches real images automatically from all seven sources. It allows inline image editing, saving to Notion with three visibility levels (private, text-only, public) and a shared gallery where teachers can browse and reuse each other’s work. It runs on both Claude Artifacts and Gemini Canvas — same code, no vendor lock-in. The print system is meticulously designed, with zoom in and out options — because printing here isn’t optional, it’s a daily necessity.

The app can also load MemoApp flashcards directly from the database, so the same cards used for studying can be transformed into beautifully designed presentations, educational games, or printable worksheets.

Completely free. A Gamma alternative in an educational format, at zero cost.

The certification system — teachers need to verify

A fundamental aspect that’s often underestimated: if I assign work, I need to be able to verify it was done. I developed a JavaScript library shared across many of my apps. Through encryption, it records student activity — questions attempted, errors, time — and generates a link the student sends to the teacher. The teacher clicks and sees exactly what was done. This isn’t a technical detail: it’s what transforms an app from a toy into a professional teaching tool. Without certification, the teacher doesn’t know if the student studied, how much, or with what results. With certification, they can follow the student’s progress remotely.

And it’s all serverless, free and privacy-respecting.

Beyond the backbone: specialized suites

Geo-quiz — the difference between a game and a tool

Let me make a comparison that illustrates the point well. Seterra is a well-known platform for studying geography. Geo-quiz is visually similar — they probably even use the same library. But the difference is enormous.

With Seterra you pick a preset deck — “EU countries”, “Asian capitals” — and practice. But you can’t select just the 20 countries you need. If you get it wrong, you move on. The teacher has no idea whether the student studied. You can’t print anything for students without a device.

With Geo-quiz, the teacher asks the AI to generate a card set — “Middle Eastern countries” — and in a few minutes the set is ready. The student practices with the intelligent regression system: if they get it wrong, they don’t move on — they review, they repeat. There’s certification for the teacher. There’s a print system.

The result: Seterra is little more than a game. Geo-quiz is a professional suite.

A concrete example: Karim, learned 20 Asian and Middle Eastern countries in 20 minutes. On his own, without these tools, he would have managed 2 or 3 — and would have forgotten them, because at home he has no study support.

ImageMap — the same logic on any image

Similar to Geo-quiz but works with images instead of maps. The user uploads an image or provides a link, asks the AI to identify specific parts — European rivers, body parts, engine components — and within moments the card set is ready. Same certification and regression systems included.

Procedural apps — step-by-step guidance

Dozens of specific apps, mostly for mathematics: how to solve expressions, how to decompose a number into prime factors, how to measure an angle, proportions, integers, monomials, equations. Each app guides the student step by step through learning a procedure. It doesn’t just tell you whether the answer is right or wrong — it teaches you the process.

Write with AI — giving a voice to those who can’t express themselves

This app solves a problem that goes beyond school. Many students with difficulties can’t write complex texts. They write disconnected thoughts. They can’t build a narrative with a logical thread. And this isn’t just an academic problem — it’s existential: they can’t express themselves.

Even helping them is hard. Writing an essay takes hours. I often have to spend a long time talking with them, and sometimes to speed things up I almost end up writing it myself.

So I created this app. It takes the assignment as input — a text, an image, a document. Then it breaks it into points and proposes them to the student, who can modify them and discuss the structure with the AI. When satisfied, they click on each point and the app helps them develop it, always through dialogue. Copy-paste is disabled.

The app doesn’t write the essay for the student. It teaches the process: break your thoughts into points, develop them one at a time, connect them. Over time, students learn to do it without the app.

The results: the stories that matter

Amir — from two words to forty pages

Amir’s greatest difficulty was a communication impairment — expressing even a slightly complex concept was already a struggle. “Can I go to the bathroom” was fine. “If the weather were nice I’d take a walk in the park behind my house” was already beyond his reach. He wasn’t independent in reading or writing, his handwriting was illegible, likely dystorthographic. He could read just a few words at a time. His academic skills were almost nonexistent — roughly a second-grade level.

His mother had a similar condition, and was a single parent. No help at home. And I had only 9 hours per week to support him across all subjects.

The first year, without a PC, almost no progress. The second year his parents finally got him a device. The change was gradual but steady. MemoApp forced him to repeat concepts, to write — and slowly it unlocked him. At school I used the same flashcards he used at home to study. Same logical thread, same content. Little by little he relearned to read, write and study.

The pivotal moment came during a GLO meeting — the operational working group with educators and specialists. The educators from the afternoon center who worked with him said it clearly, and it was recorded in the official minutes:

“The student with and without the PC is completely different. They seem like almost two distinct people. Without it he’s not independent, he does nothing. With the program he studies on his own, he’s autonomous, he learns, he reviews.”

At the end of eighth grade, Amir presented a thesis of about forty pages on the Russian Revolution — from the Tsarist era to Stalin — with a deep dive on Rimsky-Korsakov, including his role as a naval officer and the curriculum of Russian naval academies. He spoke on his own for nearly an hour straight. He scored 10 out of 10 on his oral exam — and the commission applauded him. I can assure you that’s not standard practice, and it’s a rare event.

In sixth grade he couldn’t string two words together.

But the most important part came later. Toward the end of eighth grade, Amir started handling the family’s bureaucratic affairs. He told me: “My mother has trouble making herself understood, so I do it.” One day he arrived with a letter to write to the city council. I gave him a few guidelines. He did it on his own.

At 14 he had become the anchor of his family.

From the IEP signed by the neuropsychiatrists: “The student has made use of software created by the support teacher (MemoApp, Parla, PrintMemoApp), achieving greater independence in studying. Continuation is recommended. Performance is significantly lower without these methodologies.”

Karim — the soul that couldn’t find its way out

Karim arrived from Malawi to Italy in 2020, at age 8. In sixth grade he could barely do anything. He couldn’t express himself, couldn’t put two sentences together, couldn’t read 2-3 lines and understand the context.

Karim benefited from the complete suite — he had access to all the tools refined over two decades. The results were remarkable.

The 9 out of 10 on the Russian Revolution test happened like this: I sat in on a few hours of class, took notes, generated some flashcards. I spent about twenty minutes with him, showed him the cards to study, and told him he had to practice at home. He did all the work and studying on his own. He took the test when I wasn’t there — no help from me or anyone else. Nearly flawless. A well-deserved 9.

Today he follows most of the regular class curriculum without major simplifications — unthinkable just a year and a half ago. He himself admits that “the Karim from a year and a half ago was a different person.” From isolated to perfectly integrated in the class. His independence is growing, and with it his confidence.

But the most powerful proof isn’t a grade. It’s an essay.

Karim wrote this text using the “Write with AI” app, completely on his own, without my help or anyone else’s. We’re talking about a student who couldn’t sustain more than a couple of sentences with any logical thread.

The essay is titled “Friendship as a foundation for growing together” and it begins like this:

“My name is Karim and I come from Malawi. My arrival in Italy in 2020 when I was 8 years old was an un forgettable thing. The change from Malawi to Italy was hard also for me to know that there was an other world. When I left my friends I felt sad. The main difficulties I faced were that I didn’t know how to speak it. When I saw other children playing together I felt lonely and the wall surrounded the heart.”

There are errors. “Un forgettable” split in two. “An other world.” “The wall surrounded the heart” — which probably meant “my heart was surrounded by a wall.”

But those errors are proof it’s authentic. And that phrase — the wall surrounded the heart — is involuntary poetry. This is a boy who had a whole world inside him and didn’t have the tools to let it out.

This app gave him those tools. It didn’t write the essay for him. It taught him to break his thoughts into points, develop them, connect them. And what came out was his soul. His inner world. It opened him up to the world.

And the most touching truth is that the wall only began to crumble when he was able to name it and describe it.

That is the magic of this program.

The difficulties — or rather, the system

I’m 50 years old and after more than twenty years of service I’m still on temporary contracts. Recent school reforms have made my position even more fragile: every year the risk of not being reconfirmed grows, and with it the very real risk of being pushed out of the school system entirely. If that happened, the entire suite of tools I’ve built over twenty years would die with me.

I hold no formal IT qualifications — no degree, no master’s, not even the European Computer Driving Licence. I never pursued the latter because a course at that level wouldn’t have taught me anything beyond what I’d already learned through necessity and determination, while more advanced programs are too expensive and incompatible with the schedule of a substitute teacher. The paradox is bitter: official documents and reports written by neuropsychiatrists and multidisciplinary teams confirm that the tools I developed have been “essential” in some cases for students’ independence and academic success — yet the system continues to consider me “unqualified,” both as a teacher and in IT.

Meanwhile, schools are blocking AI even for educational purposes. Mine deactivated Gemini and prevented the installation of my Claude Artifact Unlock extension, which I need to run some applications like PageCraft. The distrust of technology — born from the bad practice of many students using AI to cheat — ends up penalizing those who use it virtuously. Some colleagues are curious, others skeptical. Despite visible results, the technology I created struggles to find its place.

I have never received a single cent for my work. But this has sparked a creativity that has become innovation in itself: I developed Claude Artifact Unlock knowing that AI was free inside artifacts; I created a cross-platform library to run apps on both Claude Artifacts and Gemini Canvas without changing code; I use Notion as a free database instead of renting servers; I host everything on Netlify and GitHub Pages; I created micimici MCP to source real images from seven different providers. Every economic constraint became a technical innovation. But it’s not right to go completely unrecognized.

This is precisely why I decided to enter this and other challenges. I desperately need to emerge from anonymity and be recognized for what I’ve built. Not for me — for the suite. Because if I don’t find visibility and support, all of this dies with me. And yet this path deserves to be recognized and strengthened. I still have dozens of ideas to realize: apps that don’t exist today and that could make a difference for thousands of students.

The message

I didn’t build apps that help students. I built apps that teach students to help themselves — and give them autonomy, growth and integration. The real kind. The authentic kind.

Too often my colleagues are forced to settle for levels of learning that barely scratch the surface. Inclusion is reduced to simply being in a classroom — but you can be in a classroom all day and still be alone. You can sit through hundreds of lessons and fill out tests without ever truly understanding what was taught.

I decided that wasn’t enough. That we had to go further. That integration didn’t mean just sitting next to your classmates, but interacting with them. Understanding class dynamics. Being able to express yourself. Following lessons. Thinking.

The goal was never the grade. It was always autonomy. Amir at 14 manages his family’s paperwork. Karim tells his soul’s story in an essay. They’re not just better students — they’re freer people.

Every app is born from a real need. There’s nothing on the market that does what’s needed? I build it. The technology gets deprecated? I start over. I have no money? I find free solutions. The school blocks the tools? I find alternative paths.

My technology journey — Assembly (1999) → ActionScript → Visual Basic → VB.NET → HTML/CSS/JavaScript with AI (2022) → today an ecosystem of ~100 web apps — is not a developer’s résumé. It’s the path of a teacher who refused to accept that her students couldn’t make it.

And the results speak for themselves.

Francesca Bertolini — music and special needs teacher, self-taught programmer, on temporary contracts for twenty years, convinced that the right technology can give every student the chance to walk on their own two feet.

SMS Campaign Integration for Call Centers: 10DLC Registration, Dialer Workflows, and Cadences That Convert

Last updated: March 2026 | Reading time: ~26 minutes

Your agents dial 400 numbers a day and talk to maybe 60 of the people attached to them. The other 340 calls go to voicemail, get screened, or ring out. Meanwhile, 90% of text messages get read within 3 minutes of delivery.

That gap — between phone calls that don’t connect and text messages that get read almost instantly — is where most call centers are leaving money on the table. The existing guide on SMS campaigns for call centers covers the compliance fundamentals and cadence theory. This guide goes into the technical integration: how to wire SMS into your VICIdial dialer workflow so texts fire automatically based on call outcomes, and how to build multi-channel cadences that actually improve your contact rate.

But first, the compliance piece, because if you get that wrong, nothing else matters.

10DLC Registration: The Non-Optional First Step

Since February 1, 2025, US carriers block 100% of unregistered Application-to-Person (A2P) traffic sent over 10-digit long code (10DLC) numbers. There are no exceptions, no grace period, and no workaround. If your messages are not registered, they are not delivered.

What 10DLC Is

10DLC is a system that lets businesses send text messages using standard 10-digit phone numbers (the same numbers you use for calling) instead of short codes (5-6 digit numbers). The registration happens in two parts through The Campaign Registry (TCR):

Brand Registration — register your business entity. TCR verifies your EIN, legal name, and contact information. This is a one-time step per business.

Campaign Registration — register each SMS use case. A sales follow-up campaign is separate from an appointment reminder campaign. Each campaign gets its own throughput limits and content rules.

Registration Process and Timeline

Step Timeline Cost
Brand registration 1-5 business days $4 one-time (standard)
Brand vetting (higher throughput) 3-10 business days $40 one-time
Campaign registration 1-7 business days $10/month per campaign
Total from start to sending 1-3 weeks ~$54 to start

The timeline varies by provider. Twilio, Telnyx, and Bandwidth all handle TCR registration through their dashboards. If you are already using one of these for SIP trunking, use the same provider for SMS to simplify the setup.

Throughput Limits by Trust Score

After registration, your brand gets a trust score that determines how many messages you can send:

Trust Score Message Segments per Second Daily Cap
Low (unvetted) 0.2 ~17,000
Medium 1.0 ~86,000
High (vetted) 3.0 ~260,000

For a call center running 50 agents at 400 dials per day, you generate roughly 200-300 SMS messages per day (assuming you text every no-answer and voicemail). Even the lowest trust tier handles that volume with room to spare. But if you plan to run marketing blasts or appointment reminders in addition to disposition-triggered texts, get the brand vetting done upfront.

TCPA Compliance for Text Messages

The TCPA applies to text messages exactly like it applies to phone calls. The fines are the same: $500 per violation for unintentional, $1,500 per violation for willful. On a list of 10,000 contacts, that is $5 million to $15 million in theoretical exposure.

Consent Requirements

Since late 2023, the FCC requires one-to-one consent. A lead who gave permission to Company A does not automatically consent to messages from Company B, even if both companies bought the lead from the same source.

Your consent record must include:

Required consent documentation per subscriber:
- Phone number
- Timestamp of consent (UTC)
- IP address (for web opt-ins)
- Exact consent language shown to the subscriber
- Source (web form URL, paper form scan, verbal recording)
- Campaign(s) consented to
- Expected message frequency disclosed

Store this in your CRM or a dedicated consent database. You need it to defend against TCPA claims, and “we had consent but can not prove it” is the same as “we did not have consent” in court.

Opt-Out Handling

As of April 2025, businesses must honor opt-out requests within 10 business days and accept revocation through any reasonable method. In practice, this means:

  • Respond to STOP, UNSUBSCRIBE, CANCEL, END, and QUIT keywords automatically
  • Process opt-outs from email requests, phone calls, and web forms
  • Remove the number from all SMS campaigns (not just the one they replied to)
  • Send a confirmation message after processing the opt-out

Every outbound SMS must include opt-out instructions. The standard footer:

Reply STOP to unsubscribe. Msg & data rates may apply.

Timing Restrictions

Same as calling: no messages before 8 AM or after 9 PM in the recipient’s local time zone. Your messaging gateway needs timezone awareness, just like your dialer.

Wiring SMS Into VICIdial Workflows

VICIdial does not have native SMS sending. You need an external messaging gateway connected via API triggers. The integration pattern is straightforward: VICIdial dispositions a call, a script detects the disposition, and fires an SMS through your provider’s API.

Architecture Overview

VICIdial Agent dispositions call → vicidial_log updated
          ↓
Polling script reads new dispositions every 30-60 seconds
          ↓
Disposition-to-SMS mapping determines which template to send
          ↓
API call to Telnyx/Twilio/SignalWire sends the message
          ↓
SMS delivery status logged to sms_log table
          ↓
Inbound replies routed back to agent screen or queue

The Disposition Polling Script

This script runs as a cron job, checking for new call dispositions and firing SMS messages based on the outcome:

#!/usr/bin/env python3
"""sms_trigger.py - Send SMS based on VICIdial call dispositions"""

import os
import json
import time
import requests
import mysql.connector
from datetime import datetime, timedelta

# Configuration
DB_CONFIG = {
    "host": "localhost",
    "user": "cron",
    "password": os.environ.get("VICI_DB_PASS", ""),
    "database": "vicidial"
}

# Telnyx API (swap for Twilio/SignalWire as needed)
TELNYX_API_KEY = os.environ.get("TELNYX_API_KEY", "")
TELNYX_FROM_NUMBER = "+15551234567"
TELNYX_MESSAGING_PROFILE = os.environ.get("TELNYX_MSG_PROFILE", "")

# Disposition-to-SMS mapping
DISPOSITION_SMS_MAP = {
    "NA": {
        "template": "Hi {first_name}, we tried reaching you about {campaign_topic}. "
                    "Text back a good time to talk. Reply STOP to opt out.",
        "delay_seconds": 60,
        "max_sends": 2,
        "cooldown_hours": 24
    },
    "AM": {
        "template": "Hi {first_name}, we left you a voicemail about {campaign_topic}. "
                    "Have a quick question? Text us back. Reply STOP to opt out.",
        "delay_seconds": 120,
        "max_sends": 1,
        "cooldown_hours": 48
    },
    "CALLBK": {
        "template": "Hi {first_name}, confirming your callback for {callback_date}. "
                    "Text YES to confirm or suggest a new time. Reply STOP to opt out.",
        "delay_seconds": 30,
        "max_sends": 1,
        "cooldown_hours": 0
    },
    "SALE": {
        "template": "Thanks {first_name}! Your enrollment is confirmed. "
                    "Your rep {agent_name} is your point of contact. "
                    "Reply STOP to opt out.",
        "delay_seconds": 300,
        "max_sends": 1,
        "cooldown_hours": 0
    }
}

def get_new_dispositions(since_minutes=2):
    """Pull recent call dispositions from VICIdial."""
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor(dictionary=True)
    cursor.execute("""
        SELECT
            v.uniqueid, v.lead_id, v.user AS agent_user,
            v.status AS disposition, v.phone_number,
            v.call_date, v.campaign_id,
            l.first_name, l.last_name
        FROM vicidial_log v
        JOIN vicidial_list l ON v.lead_id = l.lead_id
        WHERE v.call_date >= NOW() - INTERVAL %s MINUTE
            AND v.status IN ('NA', 'AM', 'CALLBK', 'SALE')
            AND v.phone_number NOT IN (
                SELECT phone_number FROM sms_dnc_list
            )
            AND v.phone_number NOT IN (
                SELECT phone_number FROM sms_log
                WHERE sent_at >= NOW() - INTERVAL 24 HOUR
                AND disposition = v.status
            )
        ORDER BY v.call_date DESC
    """, (since_minutes,))
    rows = cursor.fetchall()
    cursor.close()
    conn.close()
    return rows

def send_sms(to_number, message):
    """Send SMS via Telnyx API."""
    resp = requests.post(
        "https://api.telnyx.com/v2/messages",
        headers={
            "Authorization": f"Bearer {TELNYX_API_KEY}",
            "Content-Type": "application/json"
        },
        json={
            "from": TELNYX_FROM_NUMBER,
            "to": f"+1{to_number}",
            "text": message,
            "messaging_profile_id": TELNYX_MESSAGING_PROFILE
        }
    )
    return resp.status_code == 200, resp.json()

def log_sms(lead_id, phone_number, disposition, message, status):
    """Log SMS send to database for tracking and deduplication."""
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO sms_log
            (lead_id, phone_number, disposition, message, status, sent_at)
        VALUES (%s, %s, %s, %s, %s, NOW())
    """, (lead_id, phone_number, disposition, message, status))
    conn.commit()
    cursor.close()
    conn.close()

def process_dispositions():
    """Main processing loop."""
    dispositions = get_new_dispositions(since_minutes=2)

    for dispo in dispositions:
        sms_config = DISPOSITION_SMS_MAP.get(dispo["disposition"])
        if not sms_config:
            continue

        message = sms_config["template"].format(
            first_name=dispo.get("first_name", "there"),
            campaign_topic="your recent inquiry",
            callback_date="your scheduled time",
            agent_name=dispo.get("agent_user", "your representative")
        )

        # Check timing restriction (8 AM - 9 PM local)
        hour = datetime.now().hour
        if hour < 8 or hour >= 21:
            log_sms(dispo["lead_id"], dispo["phone_number"],
                    dispo["disposition"], message, "deferred_time")
            continue

        success, resp = send_sms(dispo["phone_number"], message)
        log_sms(dispo["lead_id"], dispo["phone_number"],
                dispo["disposition"], message,
                "sent" if success else "failed")

if __name__ == "__main__":
    process_dispositions()

Database Tables for SMS Tracking

Create the tracking tables in your VICIdial database:

CREATE TABLE IF NOT EXISTS sms_log (
    id INT AUTO_INCREMENT PRIMARY KEY,
    lead_id INT NOT NULL,
    phone_number VARCHAR(20) NOT NULL,
    disposition VARCHAR(10),
    message TEXT,
    status ENUM('sent','failed','delivered','deferred_time','opted_out') DEFAULT 'sent',
    sent_at DATETIME NOT NULL,
    delivered_at DATETIME,
    response_text TEXT,
    response_at DATETIME,
    INDEX idx_phone_date (phone_number, sent_at),
    INDEX idx_lead (lead_id),
    INDEX idx_status (status, sent_at)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS sms_dnc_list (
    phone_number VARCHAR(20) PRIMARY KEY,
    opted_out_at DATETIME NOT NULL,
    opt_out_keyword VARCHAR(20),
    source VARCHAR(50) DEFAULT 'sms_reply'
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS sms_templates (
    id INT AUTO_INCREMENT PRIMARY KEY,
    template_name VARCHAR(100) NOT NULL,
    disposition VARCHAR(10),
    campaign_id VARCHAR(20),
    message_text TEXT NOT NULL,
    delay_seconds INT DEFAULT 60,
    max_sends INT DEFAULT 1,
    cooldown_hours INT DEFAULT 24,
    active TINYINT DEFAULT 1,
    created_at DATETIME DEFAULT NOW(),
    UNIQUE KEY idx_dispo_campaign (disposition, campaign_id)
) ENGINE=InnoDB;

Cron Schedule

# Run disposition-triggered SMS every 2 minutes during operating hours
*/2 8-20 * * 1-6 python3 /opt/sms-integration/sms_trigger.py >> /var/log/sms/trigger.log 2>&1

# Process inbound SMS replies every minute
* 8-21 * * * python3 /opt/sms-integration/sms_inbound.py >> /var/log/sms/inbound.log 2>&1

# Daily SMS report
0 7 * * * python3 /opt/sms-integration/sms_report.py >> /var/log/sms/daily_report.log 2>&1

Multi-Touch Cadence Design

Sending a single text after a missed call is table stakes. The real conversion improvement comes from building multi-touch cadences that alternate voice and text across multiple days.

The 7-Day High-Intent Cadence

For warm leads (web form submissions, inbound inquiries):

Day Time Channel Action
Day 1 +0 min Call Immediate call attempt
Day 1 +1 min SMS “We just tried calling about your inquiry…”
Day 1 +4 hours Call Second attempt, different time slot
Day 1 +4.5 hours SMS “Still hoping to connect…” (only if no answer)
Day 2 AM Call Morning attempt
Day 2 PM SMS Value-add content (not just “call us back”)
Day 3 PM Call Afternoon attempt
Day 4 AM SMS Social proof or case study link
Day 5 PM Call Last high-effort attempt
Day 7 AM SMS Final text with direct calendar link

The 14-Day Cold Outbound Cadence

For purchased leads or cold lists:

Day Channel Template Focus
Day 1 Call Initial contact attempt
Day 1 SMS Brief introduction + value prop
Day 3 Call Second attempt, different time
Day 5 SMS Industry stat or pain point
Day 7 Call Third attempt
Day 7 SMS Case study or testimonial
Day 10 Call Fourth attempt
Day 12 SMS Direct offer or incentive
Day 14 Call + SMS Final attempt + breakup message

The breakup message on Day 14 is surprisingly effective. Something like: “Last attempt to reach you about [topic]. If the timing isn’t right, no hard feelings. Text LATER if you want us to try again next month.”

Cadence Performance Benchmarks

Track these metrics per cadence:

Metric Good Average Poor
SMS delivery rate 97%+ 93-96% Below 93%
SMS response rate 8-15% 4-7% Below 4%
SMS opt-out rate Below 2% 2-5% Above 5%
Call + SMS contact rate 35-45% 25-34% Below 25%
Cadence-to-conversion 8-12% 4-7% Below 4%

The key metric is Call + SMS contact rate compared to call-only contact rate. If you are running 18% contact rate on calls alone and 35% with the SMS cadence layered in, that is a 94% improvement in conversations per lead — on the same list.

Handling Inbound SMS Replies

When a lead texts back, that reply needs to reach an agent fast. The response window for inbound texts is measured in minutes, not hours. A lead who texts “what time works?” at 2 PM and gets a reply at 5 PM has already moved on.

Routing Replies to Agents

Build an inbound SMS processor that checks for opt-out keywords first, then routes real replies to the agent who handled the original call:

#!/usr/bin/env python3
"""sms_inbound.py - Process inbound SMS replies"""

import os
import mysql.connector
import requests

DB_CONFIG = {
    "host": "localhost",
    "user": "cron",
    "password": os.environ.get("VICI_DB_PASS", ""),
    "database": "vicidial"
}

OPT_OUT_KEYWORDS = {"stop", "unsubscribe", "cancel", "end", "quit"}

def process_inbound_messages():
    """Fetch new inbound messages from provider and process them."""
    # Pull unprocessed inbound messages from webhook table
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor(dictionary=True)
    cursor.execute("""
        SELECT id, from_number, message_text, received_at
        FROM sms_inbound_queue WHERE processed = 0
        ORDER BY received_at ASC
    """)
    messages = cursor.fetchall()

    for msg in messages:
        phone = msg["from_number"].replace("+1", "").strip()
        text_lower = msg["message_text"].strip().lower()

        # Check for opt-out
        if text_lower in OPT_OUT_KEYWORDS:
            cursor.execute("""
                INSERT IGNORE INTO sms_dnc_list
                (phone_number, opted_out_at, opt_out_keyword) VALUES (%s, NOW(), %s)
            """, (phone, text_lower))
            # Send confirmation
            send_sms(phone, "You have been unsubscribed. No further messages will be sent.")
        else:
            # Find the original agent and create a callback
            cursor.execute("""
                SELECT v.user, v.lead_id, v.campaign_id
                FROM vicidial_log v
                JOIN sms_log s ON v.lead_id = s.lead_id
                WHERE s.phone_number = %s
                ORDER BY v.call_date DESC LIMIT 1
            """, (phone,))
            original = cursor.fetchone()

            if original:
                # Insert callback for the original agent
                cursor.execute("""
                    INSERT INTO vicidial_callbacks
                    (lead_id, list_id, campaign_id, status, user,
                     recipient, callback_time, comments)
                    SELECT lead_id, list_id, %s, 'LIVE', %s,
                           'USERONLY', NOW(), %s
                    FROM vicidial_list WHERE lead_id = %s
                """, (original["campaign_id"], original["user"],
                      f"SMS reply: {msg['message_text'][:200]}", original["lead_id"]))

        cursor.execute("UPDATE sms_inbound_queue SET processed = 1 WHERE id = %s", (msg["id"],))

    conn.commit()
    cursor.close()
    conn.close()

This creates a VICIdial callback assigned to the original agent when a lead replies by text. The agent sees the callback in their queue with the SMS reply in the comments field, giving them context before they dial back.

SMS Reply Timing

Track your reply-to-callback time. The lead texted you — they are engaged right now. Every minute of delay reduces the probability of a conversion:

Reply Time Conversion Impact
Under 5 min Peak engagement, highest close rate
5-15 min Good, slight drop-off
15-60 min Noticeable decline in engagement
1-4 hours Lead has moved on, reconnection harder
4+ hours Basically a cold re-contact

If your agents are too busy to handle callbacks quickly, create a dedicated “SMS response” campaign in VICIdial with higher priority routing.

Measuring SMS ROI

Track SMS performance separately from voice to understand the incremental value:

SELECT
    t.disposition AS trigger_dispo,
    COUNT(DISTINCT s.lead_id) AS leads_texted,
    SUM(CASE WHEN s.status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
    SUM(CASE WHEN s.response_text IS NOT NULL THEN 1 ELSE 0 END) AS responses,
    ROUND(SUM(CASE WHEN s.response_text IS NOT NULL THEN 1 ELSE 0 END) /
        GREATEST(COUNT(*), 1) * 100, 1) AS response_rate_pct,
    SUM(CASE WHEN v2.status IN ('SALE','XFER') THEN 1 ELSE 0 END) AS conversions_after_sms,
    ROUND(SUM(CASE WHEN v2.status IN ('SALE','XFER') THEN 1 ELSE 0 END) /
        GREATEST(COUNT(DISTINCT s.lead_id), 1) * 100, 1) AS sms_assisted_conv_pct
FROM sms_log s
LEFT JOIN vicidial_log v2 ON s.lead_id = v2.lead_id
    AND v2.call_date > s.sent_at
    AND v2.call_date < s.sent_at + INTERVAL 7 DAY
    AND v2.status IN ('SALE','XFER')
JOIN sms_templates t ON s.disposition = t.disposition
WHERE s.sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY t.disposition
ORDER BY sms_assisted_conv_pct DESC;

This query shows which disposition triggers produce the most SMS-assisted conversions. If your “NA” (no answer) texts lead to 6% conversions within 7 days but your “AM” (answering machine) texts produce only 1%, allocate more of your messaging budget to the NA workflow and rethink the AM template.

What to Build First

If you are starting from zero SMS integration, here is the priority order:

  1. 10DLC registration — start today, it takes 1-3 weeks. Do not wait.
  2. Consent audit — verify you have documented one-to-one consent for every contact you plan to text. If you don’t, you can not text them.
  3. Single disposition trigger — start with “NA” (no answer) only. Send one text within 60 seconds of a missed call. This is the highest-ROI single addition.
  4. Opt-out handling — make sure STOP works before you send a single message.
  5. Multi-touch cadence — once the basic trigger works, expand to the 7-day cadence.
  6. Reporting and optimization — measure delivery, response, and conversion rates. A/B test templates.

The teams at ViciStack wire SMS into dialer workflows as part of every contact rate optimization engagement because the combination of voice and text consistently outperforms either channel alone by 40-80%. If you want disposition-triggered SMS running on your VICIdial instance without spending months on the integration, we build that.

Ride Booking (Uber / Ola)

System Design: Ride Booking (Uber / Ola)

🧠 Mental Model

Uber is two concurrent real-time systems: location tracking and driver matching. Every second, millions of drivers push their GPS coordinates. When a rider requests a trip, the system finds the closest available driver, atomically assigns them, and keeps both maps in sync — all under 300ms. The hardest problems are concurrency (preventing double-booking) and geospatial search at scale.

                ┌──────────────────────────────────────────────────────────────┐
                │                     FAST PATH                                 │
 ┌──────────┐  │  ┌───────────────┐  GEORADIUS   ┌──────────────┐             │
 │  Driver  │──►│  │ Location Svc  │ ───────────► │ Match Engine │ ──► Driver  │
 │  App     │  │  │ (Redis Geo)   │              │ (top K score)│    notified  │
 └──────────┘  │  └───────────────┘              └──────┬───────┘             │
  every 3-5s   │                                        │ SETNX (atomic lock) │
               └────────────────────────────────────────┼─────────────────────┘
                                                         │
               ┌─────────────────────────────────────────▼────────────────────┐
               │                    RELIABLE PATH                               │
               │  Trip event ──► Kafka ──► Trip DB (Postgres)                  │
               │  (start, end, fare, route) — durable, for billing & history   │
               └──────────────────────────────────────────────────────────────┘

⚡ Core Design Principle

Principle Mechanism Optimizes for Can fail?
Fast Path — matching Driver app → WebSocket → Location Svc → Redis GEOADD → GEORADIUS → SETNX → push Latency (< 300ms end-to-end) Yes — Redis in-memory; re-registers within 15s
Reliable Path — billing Trip event → Kafka → Consumer → PostgreSQL Durability (zero revenue loss) No — Kafka durable log + replicated DB
Ephemeral data lives in Redis Location, driver state, SETNX lock — all TTL-bound Sub-ms reads, auto-expiry on crash Yes — intentionally ephemeral
Durable data lives in Kafka → DB trip_start, trip_end, fare, waypoints — event-sourced Correct billing, audit, replay No — Kafka retention + DB replication
State machine drives everything IDLE → RESERVED → ON_TRIP controls matching pool, update frequency Prevents double-booking; auto-heals Yes — state in Redis; re-registers on reconnect

[!IMPORTANT]
Driver location is fast path only. Location is overwritten every 3–5 seconds — only the latest value matters. Trip events are reliable path — they drive billing. Never conflate ephemeral real-time data (location) with durable transactional data (trip records).

[!NOTE]
Key Insight: Both paths run concurrently on every event. They are not sequential. The fast path can fail — the reliable path must not. Redis TTL is not a weakness; it is the correct primitive for data with a natural expiry.

1. Problem Statement & Scope

Design a ride-booking platform (Uber / Ola) supporting fare estimation, driver matching, real-time tracking, and payment — at millions of concurrent users and drivers.

In Scope: Fare estimation, ride booking, driver matching, real-time location tracking, trip start/end, ratings, payments.

Out of Scope: Driver onboarding, fleet management, surge zone boundary drawing, fraud detection internals.

2. Requirements

Functional

  1. Rider gets fare estimate (per vehicle type) for a pickup + drop location
  2. Rider books a ride; system matches a nearby available driver
  3. Driver accepts or denies the ride request
  4. Both rider and driver track each other in near real-time
  5. Trip starts and ends; rider pays; both rate each other

Non-Functional

Requirement Target Reasoning
Scale Millions of users + drivers Mandates microservices, horizontal scaling
Latency Driver matched within 60 seconds Fail the request if no match — never hang indefinitely
Availability (rider) High availability App down = rider cannot book; revenue loss
Consistency (driver assignment) Strong consistency A driver must never be assigned to two rides simultaneously
Location update Every 3–5 seconds Real-time map tracking

[!IMPORTANT]
CAP Theorem framing: This system intentionally makes different trade-offs per component. Rider-facing services (fare, history) prefer high availability. Driver assignment prefers strong consistency. You can and should say this explicitly in an interview — it shows CAP awareness at a component level.

3. Back-of-Envelope Estimations

Inputs:
  Total drivers online:       5 million
  Daily rides:                20 million
  Peak concurrent requests:   500,000
  Location update frequency:  1 per 3s (ON_TRIP), 1 per 5s (IDLE)

Location writes/sec:
  5M drivers × (1 update / 3s) = ~1.67M writes/sec → Redis must handle this

WebSocket connections (peak):
  5M drivers + ~2M active riders = ~7M persistent connections

Trip events/sec (Kafka):
  20M rides/day ÷ 86,400s = ~232 events/sec (well within Kafka capacity)

Storage:
  Trip record: ~1 KB × 20M rides/day = 20 GB/day (PostgreSQL)
  Location history (waypoints): ~500 GPS points × 16B × 20M trips = ~160 GB/day (cold storage)
  Driver metadata: 5M drivers × 1 KB = 5 GB (static, fits in memory)

Bandwidth:
  Location update frame (WebSocket): ~20 bytes
  Location update frame (HTTP polling): ~2 KB (headers + body)
  At 1.67M updates/sec: WebSocket = 33 MB/s vs HTTP = 3.3 GB/s → WebSocket wins by 100x

4. API Design

User-Facing Endpoints

Method Endpoint Request Response
GET /fare ?pickup_lat, pickup_lng, drop_lat, drop_lng [{ request_id, vehicle_type, estimated_fare }]
POST /rides { request_id } { ride_id, driver_id, driver_name, vehicle, eta_seconds }
GET /rides/history — (auth token) [{ ride_id, date, from, to, fare, driver }]
DELETE /rides/{ride_id} — { status: "cancelled" }
POST /rides/{ride_id}/rating { rating: 1–5, review } { status: "ok" }

Driver-Facing Endpoints

Method Endpoint Request Response
WS /driver/location { lat, lng, heading, speed, state } (every 1–5s) Server push: { ride_offer }
POST /rides/{ride_id}/respond `{ decision: “accept” “deny” }`
POST /rides/{ride_id}/start — { status: "in_progress" }
POST /rides/{ride_id}/end { final_distance_km } { final_fare, payment_status }

[!NOTE]
Key Insight: Driver location is a WebSocket, not REST. At 1.67M location updates/sec, HTTP overhead alone would cost 3.3 GB/sec in headers. WebSocket frames are ~20 bytes. The transport choice is arithmetic, not preference.

5. Architecture Diagrams

Simple High-Level Design

Evolved Design (with Kafka + Surge Pricing)

6. Deep Dives

6.1 Fare Estimation + Surge Pricing

Here is the problem: Fare calculation requires real-time distance, traffic, vehicle rates, and a surge multiplier. These come from different sources and must compose into a single response in < 500ms.

Flow — step by step:

Step What happens Why
1 Rider sends pickup + drop coordinates Lat/lng is the unit of truth — addresses are resolved by the client
2 Ride Service calls Google Maps API Returns distance (km) + ETA (minutes) — we don’t implement routing
3 Ride Service reads Rate DB Per-vehicle rates: e.g., bike = ₹20/km, car = ₹60/km, base fee + waiting charge
4 Ride Service calls Surge Calculator Returns current multiplier (1x, 1.5x, 2x, 3x) for that geohash cell
5 final_fare = (distance × rate + waiting_charge) × surge_multiplier One formula, all inputs composable
6 Returns [{ request_id, vehicle_type, estimated_fare }] request_id is a token — used in the booking call to prevent tampering

Surge Pricing Design:

  • Every fare request is logged to the Ride Request DB (analytics store, not transactional)
  • Surge Calculator reads this DB, computes active_requests / idle_drivers per geohash cell
  • If ratio > threshold (e.g., 1.5×): multiplier increases
  • Ride Service caches the multiplier in Redis (surge:{geohash} TTL 60s) — re-read on each fare call
  • Rider sees surge multiplier before confirming — informed consent

[!NOTE]
Key Insight: Surge pricing is a read-path concern only — it does not affect matching. The Surge Calculator is a separate service feeding data into Redis. The matching engine never touches it.

6.2 Location System

Location data is high-frequency and ephemeral. Storing it in a database creates write bottlenecks. We use Redis — not because it is fast, but because the data has no long-term value the moment the next update arrives.

Location systems trade accuracy for scalability. The frequency of updates is not a technical constraint — it is a deliberate cost/accuracy decision per driver state.

The scale problem: 1.67M driver location writes/sec, each needing sub-millisecond geospatial lookup for matching. No relational database survives this write rate.

Write Path vs Read Path

Write Path Read Path
Triggered by Driver WebSocket push every 1–5s Rider requests a trip (once per booking)
Volume 1.67M writes/sec ~500K ride requests/sec at peak
Operation GEOADD drivers:idle:{city} lng lat driver_id GEORADIUS ... 3km WITHDIST COUNT 100 ASC
Latency target < 5ms (buffered pipeline) < 10ms (bounding box scan)

[!NOTE]
Key Insight: Write path and read path never conflict. Writes overwrite one sorted set entry (O(log N)). Reads scan a bounding box (O(N+log M)). No locking. No transactions. This is why Redis Geo handles 1.67M concurrent writes while serving sub-10ms matching queries.

Update Frequency by Driver State — Accuracy vs Cost Trade-off

The problem: More frequent updates = better map accuracy for the rider. But 5M drivers × 1 update/sec = 5M Redis writes/sec. Every frequency choice is a cost/accuracy trade-off.

Driver state Update frequency Redis writes/sec (at 5M drivers) Why this frequency
IDLE Every 5s 1M writes/sec No rider watching — coarse position enough for matching
RESERVED Every 2s 2.5M writes/sec Rider watching ETA countdown on map
ON_TRIP Every 1s 5M writes/sec Rider watching live position; smooth animation required

The trade-off explicitly stated:

Dimension High frequency (every 1s always) State-adaptive frequency
Map accuracy Perfect for all states Perfect ON_TRIP, acceptable IDLE
Redis write load 5M writes/sec constant 1–5M writes/sec, scales with active trips
Cost at 5M drivers ~5M writes/sec × 24h = ~430B writes/day ~60–70% lower in normal conditions
Rider experience impact Identical for ON_TRIP Identical where it matters

Chosen: State-adaptive frequency. IDLE drivers are not being watched by a rider — 5-second position accuracy is enough for the matching geo query. ON_TRIP drivers have a rider actively watching — 1-second is required for smooth map animation.

[!NOTE]
Key Insight: Update frequency is not a single tuning knob — it is a function of driver state. Sending 1-second updates from IDLE drivers wastes 60–70% of Redis write capacity for zero rider-visible benefit. The state machine (section 6.4) already knows each driver’s state — frequency is derived from it for free.

Batching: Location Service buffers 500ms of updates, pipeline-writes to Redis in one round-trip. Publishes to Kafka only for ON_TRIP drivers (rider needs real-time map). Reduces Redis write load 3–5× without increasing visible latency.

The Fan-Out Problem (ON_TRIP)

The driver’s WebSocket lands on Server A. The rider’s WebSocket is on Server B. A direct A → B push does not exist in a distributed system.

Solution: Kafka as the cross-server fan-out bus.

[!IMPORTANT]
Fan-out via Kafka is a correctness requirement, not a performance optimization. Without it, location updates only reach the rider if they happen to be on the same server — never guaranteed.

Stale Location Self-Healing

Driver phone disconnects → WebSocket closes → Location Svc detects
  → EXPIRE driver:state:{driver_id} 30
  → After 30s with no heartbeat: key expires → auto-removed from idle pool
  → No stale drivers offered to riders. No cron job needed.

6.3 Driver Matching

Driver matching is the core of the system — everything else (payments, UI, notifications) is secondary.

Here is the problem: When a rider requests a trip, find the best available nearby driver, offer them the ride, and assign atomically — without double-booking — in under 300ms.

The matching pipeline has 5 steps. They all run sequentially in < 300ms:

[1] Geo index search → [2] Eligibility filter → [3] ETA-based ranking
         → [4] Sequential dispatch + timeout → [5] Atomic state transition

Step 1: Geo Index Search (Geohash / H3)

Why a geo index? You cannot scan 5M driver rows in a DB on every ride request. A geo index converts 2D coordinates into a prefix-searchable key — nearby drivers share a key prefix.

Geohash (traditional approach): converts (lat, lng) → alphanumeric string. Cells are rectangular.

H3 (Uber’s actual approach): hexagonal cells. Every cell has exactly 6 equidistant neighbors — no edge/corner distortion like geohash rectangles. Uber open-sourced H3; this is what production systems use.

Pickup: (12.9716, 77.5946)
Geohash (6 chars): tdr1wc  → ~1.2km × 0.6km rectangle
H3 (resolution 8): 8828308281fffff → ~460m hexagon, 6 uniform neighbors

Redis command (geohash, in practice):
GEORADIUS drivers:idle:bangalore 77.5946 12.9716 3 km
         WITHDIST COUNT 100 ASC

Returns: [(driver_001, 0.3km), (driver_002, 0.7km), ...]
Geo method Cell shape Neighbor count Used by
Geohash Rectangle 8 (varying distances) Most systems, Redis built-in
H3 Hexagon 6 (equidistant) Uber (production), rideshare at scale

[!NOTE]
Key Insight: H3 hexagons have uniform neighbor distances — no corner distortion. This matters for ETA calculation: every cell edge is equidistant, so “expand to adjacent cell” expands coverage uniformly. Geohash rectangles have longer diagonals than edges, creating search radius inconsistencies.

Step 2: Eligibility Filter

From 100 GEORADIUS candidates, filter by:
  ✓ driver.state = IDLE            (not RESERVED or ON_TRIP)
  ✓ vehicle_type matches request
  ✓ acceptance_rate > 60%
  ✓ not blocked by this rider
  ✓ rating above minimum threshold
Remaining: ~10–20 eligible candidates → rank these

Step 3: ETA-Based Ranking (Not Distance)

Matching is not about finding the nearest driver. It is about finding the fastest pickup. ETA is the metric — not distance.

A driver 0.5km away in traffic has a worse ETA than one 1.2km away on an open road. Riders experience wait time, not map distance. Every system that ranks by distance is optimizing for the wrong thing.

For each eligible driver:
  1. Call routing engine (Google Maps / internal) with driver's current location → rider pickup
  2. Get eta_seconds (accounting for real-time traffic)
  3. Score:
       score = 0.50 × (1 / eta_seconds)       # ETA is primary signal
             + 0.25 × driver_rating            # quality
             + 0.15 × acceptance_rate          # reliability
             + 0.10 × (1 / trips_today)        # avoid fatigue

Top driver = lowest ETA + highest weighted score

[!IMPORTANT]
ETA vs distance — the critical distinction. Distance is a proxy for ETA, but a bad one in cities. Real systems call the routing engine for every candidate in the top-K set. This is expensive (~10ms per call), so the candidate set must be pre-filtered to ≤ 20 drivers before ETA calculation. That is exactly what steps 1 and 2 achieve.

Step 4: Sequential Dispatch with Timeout + Fallback Chain

Real systems offer drivers sequentially (normal conditions), not all at once. Here is the exact sequence:

1. Take top-ranked driver (lowest ETA score)
2. Attempt atomic state transition: IDLE → RESERVED (see Step 5)
   → FAIL: another request already reserved this driver → go to step 1 with next candidate
   → OK: proceed
3. Push ride offer to driver's WebSocket (15-second response window)
4. Driver responds:
   → ACCEPT: confirm ride, notify rider, lock state as RESERVED
   → DENY / no response in 15s: transition back RESERVED → IDLE, go to step 1 with next candidate
5. If all candidates in current radius exhausted: expand radius (see below)
6. If all 3 radius rounds exhausted: return "no driver found" to rider

Step 5: Atomic State Transition (The Consistency Mechanism)

Here is the key insight the feedback is pointing at: Real systems do not rely on a separate distributed lock. The state transition itself is the lock.

Atomic operation (Redis Lua script or MULTI/EXEC with WATCH):

WATCH driver:state:driver_001
  current = GET driver:state:driver_001
  IF current != "IDLE": DISCARD → another request got there first → skip this driver
  MULTI
    SET driver:state:driver_001 RESERVED EX 30
    ZREM drivers:idle:bangalore driver_001
  EXEC
    → OK:   state changed atomically. Driver removed from idle pool. Send offer.
    → FAIL: EXEC returned nil (someone else committed between WATCH and EXEC) → skip driver

The state machine is the lock. There is no separate lock:assignment key — the transition from IDLE → RESERVED is the mutual exclusion. If it fails, the driver is already taken. Move on.

[!IMPORTANT]
State machine replaces distributed locks. The atomic IDLE → RESERVED transition (WATCH/MULTI/EXEC) ensures a driver is either fully available or fully reserved — never both. No separate lock key. No TTL races. The state is the truth. This is why state machines at the Redis layer are the production pattern for preventing double-booking — not external lock services.

Radius Expansion

Round 1: 2km,  2-min timeout → quality match (close driver, good ETA)
Round 2: 3km,  2-min timeout → balance quality + availability
Round 3: 5km,  2-min timeout → availability over quality
Round 4:       → fail request → "no driver found"

6.4 Driver State Machine

Real systems do not prevent double-booking with a distributed lock. They prevent it by making the state transition itself atomic. The state machine is the lock.

Why this prevents double-booking — without a separate lock:

Each state maps to exactly one pool. A driver can only be in one pool at a time:

State Pool membership Update frequency Who cares
IDLE drivers:idle:{city} sorted set (geo-indexed) Every 5s Match engine queries this
RESERVED No pool — removed from idle on transition Every 2s Rider sees “driver en route”
ON_TRIP No pool — location events go to Kafka Every 1s Rider map live tracking
OFFLINE No pool — key expired via TTL None Auto-evicted, no cleanup job

The atomic transition:

To reserve driver_001 for ride_A:

WATCH driver:state:driver_001
  current_state = GET driver:state:driver_001
  IF current_state != "IDLE":
    DISCARD          ← another request got here first → skip this driver
  MULTI
    SET driver:state:driver_001  RESERVED  EX 30
    ZREM drivers:idle:bangalore  driver_001
  EXEC
    → nil  (EXEC failed: state changed between WATCH and EXEC) → skip driver
    → OK   (atomic commit: state = RESERVED, removed from idle pool) → send offer

Two servers racing to reserve the same driver: only one EXEC succeeds. The other gets nil and moves to the next candidate. No lock key. No lock service. The state is the truth.

Crash recovery via TTL:

Driver app crashes mid-trip → WebSocket closes → heartbeat stops
  → TTL on driver:state:{driver_id} expires in 30s
  → Driver auto-transitions to OFFLINE
  → Removed from all pools automatically
  → No cleanup cron job needed

[!IMPORTANT]
The state machine replaces distributed locks. IDLE → RESERVED is the mutual exclusion. If two servers race, only one atomic WATCH/MULTI/EXEC commits. The other detects the conflict and skips. This is the production pattern — not a separate lock key, not ZooKeeper, not a DB row lock. The state machine IS the lock.

6.5 Fast Path vs Reliable Path

Fast Path Reliable Path
What Location updates, matching, map sync Trip events, fare, billing, history
Mechanism Redis Geo + WebSocket Kafka → PostgreSQL
Latency < 10ms 5–20ms Kafka lag (acceptable)
Durability Ephemeral — Redis in-memory Durable — replicated, retained
Can fail? Yes — re-registers on reconnect No — must not lose billing data

7. ⚖️ Key Trade-offs

[!TIP]
Every decision below follows: I chose X over Y because [reason at this scale]. The trade-off I accept is [downside], which is acceptable because [justification].

Trade-off 1: WebSocket vs HTTP Polling for Driver Location

Dimension WebSocket HTTP Polling
Connection overhead Persistent — one handshake, then frames New HTTP request per update (TLS + headers = ~2KB each)
Write volume at 5M drivers 1.67M × 20B frames = 33 MB/s 1.67M × 2KB headers = 3.3 GB/s
Bidirectional Yes — server pushes dispatch offer No — driver must poll for offers separately
Battery impact Low High — repeated TLS handshakes

Chosen: WebSocket.

I chose WebSocket over HTTP polling because at 5M drivers updating every 3 seconds, HTTP header overhead alone generates 3.3 GB/s of wasted bytes. WebSocket frames are ~20 bytes. The trade-off I accept is stateful sticky routing (driver must reconnect to the same server region), which is acceptable because the Location Service is partitioned by city and drivers rarely cross region boundaries mid-shift.

[!NOTE]
Key Insight: WebSocket vs HTTP is a math problem. 5M drivers × 1 update/3s × 2KB HTTP overhead = 3.3 GB/s in headers. WebSocket frames are ~20 bytes. The transport choice is arithmetic.

Trade-off 2: Redis Geo vs PostGIS for Geospatial Search

Dimension Redis + Geohash PostGIS (PostgreSQL)
Write throughput 1.67M writes/sec in-memory Disk I/O bound — saturates ~100K writes/sec
Query latency < 1ms (sorted set bounding box) 10–100ms (index scan on disk)
Data durability Ephemeral (TTL) — intentional Durable
Right for Ephemeral real-time positions Durable spatial data (surge zones, geofences)

Chosen: Redis Geo for live driver positions. PostGIS for durable spatial data (surge zone boundaries, service area polygons).

I chose Redis Geo over PostGIS because driver position data is ephemeral — the previous coordinate has zero value the moment the next arrives. The trade-off I accept is that driver locations are lost on Redis failure, which is acceptable because drivers re-register within 15 seconds via their heartbeat WebSocket.

[!NOTE]
Key Insight: Use the right tool for the data’s lifetime. Ephemeral data belongs in Redis with TTL. Durable geospatial data belongs in PostGIS. Storing current driver locations in a DB is write amplification for data with a natural expiry.

Trade-off 3: Redis SETNX vs Database Pessimistic Lock for Driver Assignment

Here is the problem: two concurrent ride requests find the same top-scored driver. Both servers try to assign driver_001. Without a lock, one driver gets two rides simultaneously.

Dimension Redis SETNX (chosen) DB Pessimistic Lock (SELECT FOR UPDATE)
Lock acquisition SETNX lock:assignment:{driver_id} ride_id EX 30 — single atomic command BEGIN; SELECT ... FOR UPDATE; UPDATE ...; COMMIT; — round-trip to DB
Latency < 1ms (in-memory) 5–50ms (disk I/O + network to DB)
DB write pressure None — Redis handles lock separately Adds row-level contention on the same DB handling trip writes
Crash recovery TTL auto-expires lock in 30s — self-healing Transaction rolls back automatically on connection drop
Dependency Redis already in stack (for geospatial queries) No new dependency — DB already exists
Scale 100K+ lock ops/sec trivially Contention at 500K concurrent requests degrades DB throughput

Chosen: Redis SETNX.

I chose Redis SETNX over a DB row lock because the same DB is already absorbing trip_start and trip_end writes from Kafka consumers. Adding 500K concurrent row locks on top of that creates contention that degrades trip write throughput. Redis is already in the stack for geospatial queries — SETNX adds zero new operational cost. The trade-off I accept is that a server crash mid-assignment leaves the lock held for up to 30s (TTL), which is acceptable because the matching engine simply picks the next candidate and retries.

[!NOTE]
Key Insight: DB SELECT FOR UPDATE is correct but expensive at scale — it serializes lock acquisition through disk I/O on the same DB under write pressure. Redis SETNX is a single in-memory atomic operation. When Redis is already in the stack, SETNX is the obvious choice: < 1ms vs 5–50ms, zero DB contention.

Trade-off 4: Sequential vs Parallel Driver Dispatch

Here is the problem: once we have a ranked list of candidates, do we offer the trip to one driver at a time (best driver first) or to all top-K simultaneously (fastest acceptance)?

Dimension Sequential Parallel
Rider wait time Higher (~45s avg) Lower (~15s avg)
Match quality Higher — best driver offered first Lower — first-to-respond wins
Notification waste Zero K−1 drivers receive a cancelled offer
Surge behavior Poor — bottleneck under high demand Good — fast fill

Chosen: Dynamic — sequential in normal supply, parallel during surge.

We do not hard-code one strategy. In normal conditions, we prioritize quality: the best-ETA driver gets the first offer, other drivers are not disturbed. In surge, we prioritize speed: notify top-K simultaneously, first to accept wins. The supply/demand ratio determines the mode. The trade-off we accept is dispatch engine complexity, which is justified by measurable improvement in rider wait time at peak demand.

[!NOTE]
Key Insight: The optimal dispatch strategy is a function of supply/demand ratio, not a fixed architecture. Hard-coding either strategy means optimal in one condition and suboptimal in the other. The threshold is tunable per city.

Trade-off 5: Dynamic Radius Expansion vs Fixed Radius

Here is the problem: a small search radius gives high-quality matches but misses drivers in sparse areas. A large radius always finds drivers but returns poor ETAs.

Dimension Fixed 2km Fixed 5km Dynamic 2→3→5km
Match quality High Lower High — starts small
Match rate in sparse areas Poor High High — expands on timeout
ETA accuracy Good Poor (15+ min) Good — expands only when needed
System load Low High (more candidates) Adaptive

Chosen: Dynamic expansion — 2km → 3km → 5km with 2-minute timeout per round.

We prioritize match quality first — start at 2km and expand only when necessary. A large fixed radius wastes ETA calculation on distant drivers and degrades rider experience with long pickup times. Expanding only on timeout means the system self-adapts to local supply conditions without manual tuning per city. The trade-off we accept is up to 4-minute matching latency in extreme sparse-supply cases, which is preferable to returning “no driver found” and forcing a re-request.

[!NOTE]
Key Insight: Fixed radius is a false economy. Starting small preserves ETA quality. Expanding only on timeout preserves match rate. The system adapts — we do not configure a radius per city.

Trade-off 6: At-Least-Once vs Exactly-Once for Trip Events

Dimension Exactly-once (Kafka 2PC) At-least-once + idempotency key
Delivery guarantee True exactly-once Effectively-once (consumer-side dedup)
Performance cost High — 2-phase commit across brokers Near-zero — Redis key check
Complexity High — transactional producer config Low — one Redis key per event_id

Chosen: At-least-once + idempotency key.

Each event: { event_id: UUID, ride_id, type, timestamp }
Consumer:   SET processed:{event_id} EX 86400 NX
  → OK:   process event
  → FAIL: duplicate — skip

I chose at-least-once + dedup over Kafka exactly-once because 2-phase commit adds broker-side overhead for a guarantee achievable more cheaply. The trade-off I accept is that idempotency logic must be implemented in every consumer — a developer discipline requirement, well-understood and low-risk.

[!NOTE]
Key Insight: Exactly-once delivery in Kafka requires 2-phase commit — expensive. At-least-once + idempotency key gives effectively-once delivery at near-zero cost. The Kafka queue is a correctness requirement (decoupling fast matching from reliable billing) — not just a performance optimization.

8. 🏁 Interview Summary

[!TIP]
When the interviewer says “walk me through your Uber design,” hit these points in order. Each is a decision with a clear WHY.

The 7 Decisions That Define This System

Decision Problem It Solves Trade-off Accepted
WebSocket (not HTTP) for location 3.3 GB/s HTTP header waste at 5M drivers Stateful sticky routing per city region
Redis Geo (not PostGIS) 1.67M location writes/sec; sub-ms spatial queries Ephemeral — re-registers within 15s on crash
SETNX atomic lock Prevents double-booking; < 1ms vs 5–50ms DB row lock under write pressure 30s TTL → rare retry on server crash
Driver State Machine IDLE/RESERVED/ON_TRIP controls pool membership + update frequency State in Redis — not durable, but self-healing
Dynamic radius expansion Balances match quality vs match rate across all supply conditions Up to 4-min match latency in worst case
Sequential vs parallel dispatch Optimal in both normal and surge conditions Dispatch engine complexity
Kafka for trip events Decouples fast matching from reliable billing 5–20ms Kafka lag on durable writes

Fast Path vs Reliable Path

Fast Path   (latency):   Driver WS → Redis GEOADD → GEORADIUS → SETNX → WS push to driver
                         ON_TRIP: Redis → Kafka → WS push to rider map

Reliable Path (safety):  trip_start / trip_end → Kafka → PostgreSQL (billing, history)
                         Fare request → Ride Request DB → Surge Calculator

Location = fast path only (ephemeral, overwritten every 1-5s)
Trip record = reliable path (durable, drives billing and audit)

Key Insights Checklist

[!IMPORTANT]
These are the lines that make an interviewer lean forward. Know them cold.

  • “Matching is not about finding the nearest driver — it is about finding the fastest pickup.” We rank by ETA, not distance. Distance is a proxy; ETA is the truth. Every system that ranks by distance is optimizing for the wrong metric.
  • “Consistency in driver assignment is enforced through state transitions, not locks.” The atomic IDLE → RESERVED transition is the mutual exclusion. No separate lock service. No ZooKeeper. The state is the truth.
  • “Location data is high-frequency and ephemeral — storing it in a DB creates write bottlenecks.” Redis holds only the current position. TTL self-evicts stale data. The previous coordinate has zero value the moment the next one arrives.
  • “Sequential vs parallel dispatch is a supply/demand decision.” Hard-coding either strategy is wrong. Normal demand = sequential (quality). Surge = parallel (speed). The threshold is tunable per city.
  • “The Kafka queue is a correctness requirement.” Decoupling fast matching (Redis, sub-100ms) from reliable billing (Kafka → DB) is what makes both guarantees achievable simultaneously.
  • “CAP per component.” Rider-facing services are AP. Driver assignment is CP. The system is not uniformly one or the other — this is the right answer.

9. Frontend Notes

Frontend / Backend split for Uber: 50% / 50% — but the interview weight is heavily backend. Frontend deserves mention, not a full section.

Concept What to say in an interview
Maps SDK Google Maps / Mapbox SDK renders driver markers. Real-time position updates come via WebSocket → client calls marker.setPosition(lat, lng) each frame. Dead reckoning (linear interpolation using heading + speed) smooths animation between 1s server ticks at 60fps.
WebSocket client Single persistent connection per session. Driver app: push location on interval, listen for ride offers. Rider app: listen for driver position updates + trip status. On disconnect: exponential backoff reconnect, buffer location updates locally, flush on reconnect.
Optimistic UI Booking confirmation shown immediately on POST /rides 200. Driver assignment details populated when WebSocket offer fires. Cancel is immediately reflected client-side; server reconciles async.

Solana’s Permanent Delegate Burn Scam: How Token-2022 Extensions Power 2026’s Largest Automated Rug Pull Factory — And a Detection Pipeline to Stop It

In March 2026, Solana’s network degradation isn’t coming from a protocol bug — it’s coming from an industrial-scale scam token factory exploiting Token-2022’s Permanent Delegate extension to burn victims’ tokens seconds after purchase. RugCheck.xyz flags over 40% of new Solana tokens as using this extension. Here’s how the attack works at the bytecode level, and a complete detection pipeline you can deploy today.

The Permanent Delegate Attack Flow

Token-2022 (SPL Token 2022) introduced token extensions — powerful primitives for compliance, privacy, and programmability. The PermanentDelegate extension grants a designated authority unconditional power to transfer or burn any holder’s tokens without their signature.

The intended use case: regulatory compliance (freezing sanctioned addresses). The actual use in 2026: automated theft.

Attack Sequence (Step by Step)

1. Attacker deploys token via Token-2022 with PermanentDelegate = deployer wallet
2. Creates liquidity pool on Raydium/Orca with SOL pairing
3. Generates fake volume via wash trading bots (10-50 coordinated wallets)
4. Victims buy on DEX aggregators — token appears in their wallet
5. Within 1-60 seconds: Permanent Delegate calls Burn on victim's token account
6. Victim's balance → 0, but they already paid SOL
7. Attacker drains LP (sells remaining tokens for SOL)
8. Repeat with new mint address — entire cycle takes < 5 minutes

The On-Chain Signature

Here’s what the Permanent Delegate burn instruction looks like in Anchor/Rust:

use anchor_lang::prelude::*;
use anchor_spl::token_2022::{self, Token2022, BurnChecked};

// This is what the ATTACKER's program calls
pub fn burn_victim_tokens(ctx: Context<BurnVictim>, amount: u64, decimals: u8) -> Result<()> {
    // No victim signature required — Permanent Delegate has unconditional authority
    let cpi_accounts = BurnChecked {
        mint: ctx.accounts.mint.to_account_info(),
        from: ctx.accounts.victim_token_account.to_account_info(),
        authority: ctx.accounts.permanent_delegate.to_account_info(),  // attacker's key
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
    token_2022::burn_checked(cpi_ctx, amount, decimals)?;
    Ok(())
}

#[derive(Accounts)]
pub struct BurnVictim<'info> {
    pub mint: AccountInfo<'info>,
    /// CHECK: Victim's token account — no signature needed
    #[account(mut)]
    pub victim_token_account: AccountInfo<'info>,
    pub permanent_delegate: Signer<'info>,  // only the delegate signs
    pub token_program: Program<'info, Token2022>,
}

The critical detail: no victim signature appears anywhere. The Permanent Delegate is the sole signer.

Why Existing Tools Miss It

Standard token safety checkers look for:

  • ❌ Mint authority (can mint new tokens) — Permanent Delegate doesn’t need to mint
  • ❌ Freeze authority (can freeze accounts) — Permanent Delegate burns, not freezes
  • ❌ LP lock status — attacker can lock LP and still profit via the burn

The Permanent Delegate extension is distinct from mint/freeze authorities. A token can have mint authority revoked, freeze authority revoked, LP locked — and still rug via Permanent Delegate burn.

Detection Pipeline: 4 Layers

Layer 1: On-Chain Extension Scanner (TypeScript)

Query the mint account directly and decode Token-2022 extensions:

import { Connection, PublicKey } from '@solana/web3.js';
import { 
  getMint, getExtensionData, ExtensionType,
  TOKEN_2022_PROGRAM_ID 
} from '@solana/spl-token';

interface TokenRiskReport {
  mint: string;
  hasPermanentDelegate: boolean;
  delegateAddress: string | null;
  delegateIsDeployer: boolean;
  hasTransferFee: boolean;
  transferFeeBps: number;
  hasNonTransferableFlag: boolean;
  riskScore: number;  // 0-100
}

async function scanTokenExtensions(
  connection: Connection,
  mintAddress: PublicKey
): Promise<TokenRiskReport> {
  const mintInfo = await getMint(
    connection, mintAddress, 'confirmed', TOKEN_2022_PROGRAM_ID
  );

  let report: TokenRiskReport = {
    mint: mintAddress.toBase58(),
    hasPermanentDelegate: false,
    delegateAddress: null,
    delegateIsDeployer: false,
    hasTransferFee: false,
    transferFeeBps: 0,
    hasNonTransferableFlag: false,
    riskScore: 0,
  };

  // Check Permanent Delegate extension
  try {
    const delegateData = getExtensionData(
      ExtensionType.PermanentDelegate, 
      mintInfo.tlvData
    );
    if (delegateData) {
      report.hasPermanentDelegate = true;
      report.delegateAddress = new PublicKey(delegateData.slice(0, 32)).toBase58();
      report.riskScore += 60;  // Highest single risk factor

      // Check if delegate == mint authority (deployer pattern)
      if (mintInfo.mintAuthority && 
          report.delegateAddress === mintInfo.mintAuthority.toBase58()) {
        report.delegateIsDeployer = true;
        report.riskScore += 20;
      }
    }
  } catch {}

  // Check Transfer Fee extension (hidden fee = potential drain)
  try {
    const feeData = getExtensionData(
      ExtensionType.TransferFeeConfig, 
      mintInfo.tlvData
    );
    if (feeData) {
      report.hasTransferFee = true;
      const feeBps = feeData.readUInt16LE(0);
      report.transferFeeBps = feeBps;
      if (feeBps > 500) report.riskScore += 15;  // >5% fee is suspicious
    }
  } catch {}

  return report;
}

Layer 2: Deployer Wallet Profiling (Python)

Scam factories reuse deployer wallets. Profile them:

import asyncio
from solders.pubkey import Pubkey
from solana.rpc.async_api import AsyncClient

async def profile_deployer(rpc_url: str, deployer: str) -> dict:
    """Score deployer wallet for scam factory patterns."""
    client = AsyncClient(rpc_url)
    deployer_key = Pubkey.from_string(deployer)

    # Get all token mints created by this wallet
    sigs = await client.get_signatures_for_address(deployer_key, limit=200)

    token_mints = []
    burn_txs = 0
    lp_creates = 0

    for sig_info in sigs.value:
        tx = await client.get_transaction(
            sig_info.signature, max_supported_transaction_version=0
        )
        if not tx.value:
            continue

        logs = tx.value.transaction.meta.log_messages or []
        for log in logs:
            if "InitializeMint2" in log:
                token_mints.append(sig_info.signature)
            if "Burn" in log and "PermanentDelegate" in str(logs):
                burn_txs += 1
            if "InitializePool" in log or "initialize" in log.lower():
                lp_creates += 1

    risk_indicators = {
        "deployer": deployer,
        "tokens_created": len(token_mints),
        "burn_via_delegate": burn_txs,
        "lp_pools_created": lp_creates,
        "is_factory": len(token_mints) > 5 and burn_txs > 0,
        "factory_score": min(100, len(token_mints) * 8 + burn_txs * 15),
    }

    await client.close()
    return risk_indicators

Layer 3: Real-Time Burn Monitor (TypeScript + WebSocket)

Catch Permanent Delegate burns as they happen:

import { Connection, PublicKey, LogsFilter } from '@solana/web3.js';

function monitorDelegateBurns(rpcWsUrl: string) {
  const connection = new Connection(rpcWsUrl, 'confirmed');

  // Subscribe to Token-2022 program logs
  const TOKEN_2022 = new PublicKey(
    'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
  );

  connection.onLogs(TOKEN_2022, (logs) => {
    const logStr = logs.logs.join('n');

    // Detect Burn instruction where signer != token owner
    if (logStr.includes('Instruction: BurnChecked') || 
        logStr.includes('Instruction: Burn')) {

      // Parse accounts from the instruction
      // Account[0] = token account (victim)
      // Account[1] = mint
      // Account[2] = authority (should be owner, but delegate if scam)

      console.log(`⚠️ DELEGATE BURN DETECTED`);
      console.log(`  TX: ${logs.signature}`);
      console.log(`  Slot: ${logs.context?.slot}`);

      // Alert pipeline: webhook, Telegram bot, or Forta agent
      alertWebhook({
        type: 'permanent_delegate_burn',
        signature: logs.signature,
        timestamp: Date.now(),
        logs: logs.logs,
      });
    }
  }, 'confirmed');

  console.log('🔍 Monitoring Token-2022 burns via Permanent Delegate...');
}

async function alertWebhook(data: any) {
  await fetch(process.env.ALERT_WEBHOOK!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
}

Layer 4: Pre-Trade Safety Gate (Anchor Program)

Enforce on-chain — reject swaps involving tokens with Permanent Delegate:

use anchor_lang::prelude::*;
use spl_token_2022::extension::{BaseStateWithExtensions, StateWithExtensions};
use spl_token_2022::state::Mint as Mint2022;

/// Verifies a Token-2022 mint has no dangerous extensions before allowing a swap
pub fn verify_safe_token(mint_info: &AccountInfo) -> Result<()> {
    let mint_data = mint_info.try_borrow_data()?;

    // Parse as Token-2022 mint with extensions
    let mint_state = StateWithExtensions::<Mint2022>::unpack(&mint_data)
        .map_err(|_| error!(ErrorCode::InvalidMint))?;

    // CRITICAL: Reject tokens with Permanent Delegate
    if mint_state.get_extension::<spl_token_2022::extension::permanent_delegate::PermanentDelegate>().is_ok() {
        return Err(error!(ErrorCode::DangerousPermanentDelegate));
    }

    // Also flag: TransferFee > 10% (hidden fee drain)
    if let Ok(fee_config) = mint_state
        .get_extension::<spl_token_2022::extension::transfer_fee::TransferFeeConfig>() 
    {
        let fee_bps: u16 = fee_config.newer_transfer_fee.transfer_fee_basis_points.into();
        if fee_bps > 1000 {
            return Err(error!(ErrorCode::ExcessiveTransferFee));
        }
    }

    Ok(())
}

#[error_code]
pub enum ErrorCode {
    #[msg("Token has Permanent Delegate extension — potential burn rug")]
    DangerousPermanentDelegate,
    #[msg("Token has >10% transfer fee — potential hidden drain")]
    ExcessiveTransferFee,
    #[msg("Invalid mint account")]
    InvalidMint,
}

CI/CD Integration: GitHub Actions

Add this to your Solana project’s CI pipeline:

name: Token Extension Security Scan
on:
  pull_request:
    paths: ['programs/**', 'tests/**']

jobs:
  extension-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check for Permanent Delegate usage
        run: |
          echo "🔍 Scanning for Permanent Delegate patterns..."
          # Flag any code that initializes PermanentDelegate extension
          if grep -rn "PermanentDelegate|permanent_delegate" programs/; then
            echo "⚠️ WARNING: PermanentDelegate extension detected"
            echo "Review required: ensure delegate authority has proper governance"
            # Don't auto-fail — legitimate compliance use exists
            echo "::warning::PermanentDelegate extension found — manual review required"
          fi

          # Flag unconditional burn patterns (no multisig/timelock)
          if grep -rn "burn_checked|BurnChecked" programs/ | grep -v "timelock|governance|multisig"; then
            echo "⚠️ Burn instruction without governance guard detected"
          fi

      - name: Run Anchor tests with extension checks
        run: |
          anchor test 2>&1 | tee test-output.log
          # Verify tests cover extension validation
          if ! grep -q "DangerousPermanentDelegate|verify_safe_token" test-output.log; then
            echo "::warning::No extension safety tests detected in test suite"
          fi

10-Point Token-2022 Extension Security Checklist

Before interacting with any Token-2022 token:

# Check Tool
1 Permanent Delegate exists? If yes → who controls it? spl-token display <mint>
2 Delegate == deployer wallet? (rug factory pattern) On-chain scanner
3 Transfer Fee > 5%? (hidden drain) Extension decoder
4 Transfer Hook points to unverified program? getExtensionData()
5 Deployer created >5 tokens in past 7 days? (factory) Wallet profiler
6 Any Permanent Delegate burns in token history? Transaction scanner
7 LP locked? (not sufficient alone — check #1 first) LP lock checker
8 Mint authority revoked? (still rugable via delegate) Mint account data
9 Non-Transferable flag set? (honeypot variant) Extension decoder
10 Token age < 24h + high volume? (pump phase) DEX analytics

Critical insight: Checks 7 and 8 alone are not sufficient. A token can have LP locked and mint authority revoked while still being a rug via Permanent Delegate burn. Always start with check #1.

The Broader Pattern: Extension Abuse Beyond Permanent Delegate

Token-2022’s extension model creates a new attack surface taxonomy:

  • Permanent Delegate → unauthorized burn/transfer (this article)
  • Transfer Fee → hidden tax up to 100% on every transfer
  • Transfer Hook → arbitrary program execution on every transfer (can revert sells = honeypot)
  • Non-Transferable → tokens that can never be sold (lock-in scam)
  • Confidential Transfer → obscured amounts that hide whale dumps

Each extension was designed for legitimate use. Each has been weaponized. The security community needs extension-aware tooling as a first-class primitive, not an afterthought.

Conclusion

The Permanent Delegate burn scam isn’t a bug — it’s a feature being weaponized at industrial scale. In Q1 2026 alone, conservative estimates put losses from Token-2022 extension abuse at $50M+ across thousands of individual victims.

The defense isn’t complicated: scan extensions before you swap. The four-layer pipeline above — on-chain scanner, deployer profiler, real-time monitor, and on-chain safety gate — covers detection from pre-trade to post-trade.

Every Solana DEX aggregator, wallet, and trading bot should be checking Token-2022 extensions by default. Until they do, the factory keeps running.

This is part of the DeFi Security Research series. Code examples are educational — audit thoroughly before production use.

The Two-Tool Problem: ESLint, Prettier, and the Case for a Unified Toolchain

Deep dive · Tooling · ~18 min read · ESLint · Prettier · Biome · TypeScript

Most JavaScript projects ship with both ESLint and Prettier without anyone really understanding why two separate tools are needed — or why combining them into one is harder than it looks. This is the post that should have existed when I first set up a new project and stared at four config files doing roughly the same thing.

Table of Contents

  1. The right mental model, first
  2. ESLint: what it actually does under the hood
  3. Prettier: why it reprints instead of fixing
  4. The overlap zone: where things get messy
  5. Configuring both properly in 2026
  6. Why one tool couldn’t do this for so long
  7. Biome: the serious attempt at unification
  8. A real comparison across a codebase
  9. What Biome still cannot do
  10. Verdict and migration strategy

The right mental model, first

The confusion starts because both tools ship as dev dependencies, both produce errors in your terminal, both integrate with your editor, and both run in CI. They touch the same files. So the reasonable assumption is that they’re doing the same thing — just with different opinions about it.

That assumption is wrong, and it’s the root of every misconfiguration I’ve seen in the wild.

The cleanest way to think about this: ESLint and Prettier are answering completely different questions.

Dimension ESLint Prettier
Core question Is this code correct and safe? Does this code look consistent?
Domain Semantics & logic Aesthetics & layout
Operates on AST (abstract syntax tree) Raw text → reprinted output
Can it catch bugs? ✅ Yes ❌ Never
Output Errors & warnings with location Reformatted file (always)
Auto-fixable? Partially — many rules aren’t auto-fixable Always — the whole point
Configurable? Deeply — write custom rules, use plugins Barely — intentionally opinionated
Framework-aware? Yes, via plugins (React, Angular, Vue, etc.) Language-aware only, not framework-aware

Neither is a subset of the other. They’re orthogonal tools that happen to share a pipeline step. Internalizing that distinction makes every configuration decision obvious.

ESLint: what it actually does under the hood

ESLint parses your source into an Abstract Syntax Tree — a structured, in-memory representation where every piece of code becomes a typed node. A function call is a CallExpression node. A variable declaration is a VariableDeclaration node. ESLint then runs a traversal over this tree, calling registered rule handlers at each node type.

This is why ESLint can catch things like:

  • A variable that’s declared but never referenced (no-unused-vars)
  • A promise-returning function whose return value is discarded (no-floating-promises)
  • A React hook called inside a conditional (react-hooks/rules-of-hooks)
  • Potential XSS via unsanitized innerHTML assignment (no-unsanitized)
  • TypeScript type errors surfaced without running tsc (@typescript-eslint/no-unsafe-assignment)

These are semantic problems. The tool is reasoning about what your code means and whether that meaning is sound — not what it looks like on screen.

// ESLint sees this as: VariableDeclaration → VariableDeclarator → Identifier("result")
// It knows "result" is never used elsewhere in scope. Prettier has no concept of scope.

async function fetchUser(id: string) {
  const result = fetch(`/api/users/${id}`);  // no-floating-promises: missing await
  const userData = await getProfile(id);     // no-unused-vars: userData declared, never read

  return null;
}

ESLint also runs scope analysis — it maintains a symbol table as it traverses, tracking where variables are defined and referenced. That’s what makes rules like no-shadow and no-use-before-define possible. Prettier processes the same file as text and reprints it — it has no notion of a symbol table.

The plugin architecture that makes ESLint powerful

ESLint’s real strength is that rule-writing is a public API. A plugin is just a Node.js module that exports rules, each of which is a visitor object — a set of handlers keyed by AST node type. The React Hooks plugin, for example, registers a visitor on CallExpression nodes, checks whether the callee looks like a hook, and then traverses the call stack to validate that hooks are only called from the top level of a function component.

This is why the ecosystem is so rich. Any team can encode their own invariants — domain rules, API usage contracts, security constraints — and enforce them statically. That kind of extensibility is architecturally incompatible with what Prettier is trying to do.

Prettier: why it reprints instead of fixing

Prettier also parses your code into an AST — but then it does something fundamentally different with it. It throws away your original formatting entirely and reprints the file from scratch using its own layout algorithm. Every run of Prettier on the same logical code produces the exact same output. This is called idempotent formatting.

“Prettier takes your code and reprints it from scratch by taking the line length into account, wrapping code when necessary.”
— Prettier docs

The example below shows what this means in practice. You can hand Prettier wildly different formatting of the same logical code, and it produces identical output every time.

Before (anything goes):

const     user   =   {
name:    "Rajat",
    role:   "engineer",
  active:true
}

function greet(u,prefix,suffix){
  return `${prefix} ${u.name} ${suffix}`
}

After Prettier (always the same output):

const user = {
  name: "Rajat",
  role: "engineer",
  active: true,
};

function greet(u, prefix, suffix) {
  return `${prefix} ${u.name} ${suffix}`;
}

Prettier intentionally offers almost no configuration. You can set quote style, semicolons, tab width, trailing commas, and print width — that’s essentially it. This is by design. The whole value proposition is that formatting ceases to be a decision. No more code review comments about semicolons. No more team debates about line length. Prettier decides, everyone accepts it, and you move on to the actual work.

Note: Prettier’s inflexibility is the feature. If it let you configure everything, teams would spend time configuring it — defeating the entire purpose of having a formatter in the first place. This is a conscious and correct tradeoff.

The overlap zone: where things get messy

Here’s where the confusion compounds. ESLint has always had stylistic rules — rules about indentation, quote style, semicolons, spacing — that overlap with what Prettier manages. For years, linters were the only tool doing any formatting, so these rules existed for a reason. But once Prettier entered the picture, you now have two systems with conflicting opinions about the same bytes.

A classic conflict: ESLint’s quotes rule set to "double", Prettier’s singleQuote: true. ESLint rewrites to double quotes, Prettier rewrites to single, they fight forever and your editor goes insane on every save.

# The classic conflict — what developers actually experienced

# ESLint (with quotes: ["error", "double"]) fixes this:
const name = 'Rajat';   →   const name = "Rajat";

# Prettier (with singleQuote: true) then immediately fixes it back:
const name = "Rajat";   →   const name = 'Rajat';

# Infinite loop. Editor goes to war with itself on every save.

The solution the community landed on is eslint-config-prettier — a shareable ESLint config that disables all ESLint rules that conflict with Prettier. You put it last in your config array so it overrides anything that might fight Prettier. Think of it as a white flag: ESLint fully surrenders the formatting territory to Prettier.

Warning: eslint-plugin-prettier — which runs Prettier as an ESLint rule — is a different beast and generally the wrong approach. It chains Prettier’s output into ESLint’s pipeline, creating double-parsing overhead, slower CI runs, and confusing error messages that blend linting and formatting in the same output. The ESLint team and typescript-eslint both recommend against it.

Configuring both properly in 2026

ESLint’s flat config (eslint.config.js) is now the default, with defineConfig() from eslint/config being the current recommended helper as of ESLint v9. The old tseslint.config() wrapper is deprecated. Here’s a well-structured setup for a TypeScript + React project:

// eslint.config.js
// @ts-check
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import prettierConfig from "eslint-config-prettier";

export default defineConfig(
  // 1. Base JS rules
  eslint.configs.recommended,

  // 2. TypeScript rules (strict = recommended + extra correctness rules)
  ...tseslint.configs.strictTypeChecked,

  // 3. Type-aware rules require pointing at your tsconfig
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },

  // 4. React Hooks rules
  {
    plugins: { "react-hooks": reactHooks },
    rules: reactHooks.configs.recommended.rules,
  },

  // 5. Your own overrides
  {
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/consistent-type-imports": "error",
      "no-console": ["warn", { allow: ["warn", "error"] }],
    },
  },

  // 6. Prettier LAST — disables all conflicting ESLint style rules.
  // This is a config object (not a plugin), so it just turns off rules.
  prettierConfig,
);
// .prettierrc
{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf"
}

For your package.json scripts, run them separately — never chain Prettier through ESLint:

{
  "scripts": {
    "lint":           "eslint . --max-warnings 0",
    "lint:fix":       "eslint . --fix",
    "format":         "prettier --write .",
    "format:check":   "prettier --check .",
    // CI: format check first (fast), then full lint
    "ci:quality":     "prettier --check . && eslint . --max-warnings 0"
  }
}

Tip: Run Prettier before ESLint in CI. Prettier is dramatically faster (formatting doesn’t need to build a type-aware AST), so failing fast on a formatting issue saves the time of running the full ESLint pass on badly-formatted code.

The config files you end up with

In a typical TypeScript monorepo this expands to: eslint.config.js, .prettierrc, .prettierignore, plus any workspace-level overrides. That’s already 3–4 files, with dependencies like @typescript-eslint/parser, typescript-eslint, eslint-config-prettier, and prettier itself — each with their own peer dependency trees. For a bare create-next-app this routinely pulls in 60–80 packages just to make linting and formatting work.

This is not a deal-breaker. It’s tooling; it’s meant to be set up once. But it does mean there’s real maintenance surface area — and it’s the problem that Biome is trying to solve at the architectural level.

Why one tool couldn’t do this for so long

The obvious follow-up question: why wasn’t this solved years ago? If both tools parse an AST, why can’t one tool do both passes?

The answer is architectural, and it’s worth understanding — because it also explains why Biome’s design is genuinely novel rather than just marketing.

Formatting and linting have opposite design requirements

A good formatter must be deterministic and opinionated. You want exactly one correct output for any given input. The moment you start making rules configurable, you invite teams to configure them differently, which reintroduces the formatting debates the tool was meant to eliminate. Prettier’s intentional inflexibility is the product.

A good linter must be extensible and configurable. Every codebase has different invariants — a fintech product has security rules a portfolio site doesn’t need, a React app has hook rules that an Angular app doesn’t care about. You need a plugin system, per-file overrides, custom rule authoring. If you lock the linter down the way Prettier locks the formatter down, it becomes useless for most real projects.

Core tension: The qualities that make a great formatter (inflexible, opinionated, deterministic) are architecturally opposed to the qualities that make a great linter (extensible, configurable, context-sensitive). Building one tool that does both well requires solving this at the design level — not just merging two CLIs into one binary.

The performance problem with naive combinations

When you use eslint-plugin-prettier (the approach that runs Prettier as an ESLint rule), you’re forcing ESLint to:

  1. Parse the file into an AST
  2. Run all linting rules
  3. Apply auto-fixes (including Prettier’s formatting changes)
  4. Re-parse the now-modified file
  5. Run rules again to verify fixes didn’t introduce new violations

On a project with type-aware TypeScript rules, each parse-and-lint cycle is already slow because it needs to construct a full type program. Running Prettier inside that cycle multiplies the pain. This is why, on large codebases, eslint --fix with Prettier embedded can take 30–60 seconds — and why some teams disable type-aware rules entirely just to keep CI manageable.

Biome: the serious attempt at unification

Biome (originally forked from the Rome project) is the most credible attempt to build a unified toolchain from scratch. It’s written in Rust, ships as a single binary, and takes a fundamentally different architectural approach that makes the one-tool dream actually tractable. As of early 2026, it’s at v2.4 and has surpassed 15 million monthly downloads.

The key architectural insight: parse once, use everywhere

Biome parses your code into its own CST (Concrete Syntax Tree — preserves more information than an AST, including whitespace) exactly once, then runs both the formatter and the linter over it in separate passes using the same in-memory tree. This eliminates the redundant parsing that makes chained tools slow.

The formatter and linter are implemented as separate visitors over the same tree, so they don’t interfere with each other’s operation — but they also don’t need to pay the parsing cost twice.

// biome.json (v2.4)
{
  "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",

  "organizeImports": {
    "enabled": true
  },

  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },

  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedVariables": "error",
        "useExhaustiveDependencies": "error"
      },
      "suspicious": {
        "noConsoleLog": "warn",
        "noExplicitAny": "error"
      },
      "style": {
        "useTemplate": "error",
        "noParameterAssign": "warn"
      },
      "performance": {
        "noDelete": "warn"
      }
    }
  },

  "javascript": {
    "formatter": {
      "quoteStyle": "double",
      "semicolons": "always",
      "trailingCommas": "all",
      "arrowParentheses": "always"
    }
  },

  "files": {
    "ignore": ["dist", ".next", "node_modules", "coverage"]
  },

  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  }
}

That single file replaces: eslint.config.js, .prettierrc, .prettierignore, and the coordination glue between them.

The commands

npx @biomejs/biome check .          # lint + format check (reports only)
npx @biomejs/biome check --write .  # lint + format + auto-fix
npx @biomejs/biome format --write . # format only
npx @biomejs/biome lint --write .   # lint + auto-fixable rules only

# Migration from an existing project:
npx @biomejs/biome migrate eslint --write   # reads eslint.config.js / .eslintrc and ports rules
npx @biomejs/biome migrate prettier --write # reads .prettierrc and ports options

Error output that’s actually useful

One underrated improvement: Biome’s error messages are designed to explain the intent of a rule, not just flag the location. Compare:

ESLint:

✖  1 problem (1 error, 0 warnings)

src/utils.ts
  12:7  error  'result' is assigned a value but never used
        @typescript-eslint/no-unused-vars

Biome:

lint/correctness/noUnusedVariables

✖ This variable is declared but never used.

10 │ function processData(items) {
11 │   const TAX = 0.08;
>  12 │   const result = transform(items);
          ^^^^^^
13 │   return items.length;
14 │ }

ℹ Unused variables are often a sign of incomplete refactoring.

The diagnostic gives you the rule category, a contextual code frame, and an explanatory note about why the rule exists. It’s a small thing that adds up fast when you’re onboarding a team.

A real comparison across a codebase

Speed benchmarks across the community are consistent. On a Next.js + TypeScript project with 300+ source files, the difference is stark. Note that these are relative numbers — exact timing depends on machine, file count, and rule complexity.

Tool Time (312 files) Relative
ESLint + Prettier ~28s baseline
Biome check ~1.4s ~20× faster
Oxlint (lint only) ~0.6s ~50× faster

This matters for two practical reasons: CI pipeline time (where every second costs money and developer attention), and editor feedback latency (where slow linting breaks the tight write-see-error-fix loop that makes static analysis valuable in the first place).

Package weight comparison

# ESLint + Prettier setup for TypeScript + React
$ npm install --save-dev 
    eslint @eslint/js typescript-eslint 
    eslint-plugin-react-hooks 
    eslint-config-prettier prettier

added 127 packages   # config files: 3-4

# ─────────────────────────────────────

# Biome equivalent
$ npm install --save-dev --save-exact @biomejs/biome

added 1 package      # config files: 1

Note: The package count difference is somewhat misleading — those 127 packages are mostly shared transitive deps that other tools also use. But the config file consolidation is genuinely significant in large repos and monorepos, where config drift between workspaces is a real problem.

What Biome still cannot do

Biome is production-ready for most TypeScript/React/Next.js projects as of v2.4. But there are genuine gaps worth knowing before you migrate. This isn’t a reason to avoid Biome — it’s information to make an honest decision.

Plugin ecosystem — ESLint has a decade of community plugins: eslint-plugin-testing-library, eslint-plugin-security, eslint-plugin-unicorn, and hundreds more. Biome’s plugin story via GritQL (introduced in v2.0) is live and growing, but not yet at parity with ESLint’s ecosystem depth. If you depend on specific plugins, check coverage first at biomejs.dev/linter/json/rules.

Deep type-aware rules — typescript-eslint in type-checked mode can run rules against your full type program, catching things like no-unsafe-assignment across module boundaries. Biome’s type inference engine (expanded in v2.4 to resolve Record<K, V> types and improve noFloatingPromises accuracy) is improving but not yet at full parity with typescript-eslint’s depth.

Language coverage — Prettier formats Vue SFCs, SCSS, YAML, Markdown, GraphQL, and MDX. Biome v2.4 covers JS, TS, JSX, TSX, JSON, CSS, and GraphQL, with experimental support for Vue, Svelte, and Astro. SCSS support is in active development. If your project formats Markdown or YAML through Prettier, you’ll need to keep it for those file types or use a separate tool.

Angular / Vue template linting — angular-eslint parses Angular templates with Angular’s own compiler and runs template-aware rules. There’s no Biome equivalent yet. Vue’s eslint-plugin-vue similarly operates on SFC templates. If you’re on Angular or Vue and relying on template linting, keep ESLint for now.

eslint-disable migration — Biome uses biome-ignore comments, not eslint-disable. During migration, Biome reveals how many suppression comments your codebase has accumulated — and they won’t carry over automatically. This can surface a lot of latent tech debt at once. Factor that into your migration sprint planning.

JSON-only config — Biome’s config is JSON (or JSONC). You can’t write it in JavaScript, which means no dynamic config based on environment variables or computed values — a minor but real limitation for projects with complex per-environment rule sets.

The honest hybrid strategy

For projects that need the ESLint plugin ecosystem — particularly Angular, Vue, or any project with deep security rule requirements — the pragmatic path is:

  • Use Biome for formatting and the core lint rules it covers (it’s faster and cleaner)
  • Keep ESLint for the specific plugins that Biome doesn’t have equivalents for yet
  • Drop Prettier entirely (Biome’s formatter scores 97%+ Prettier compatibility and is significantly faster)

You still cut the config complexity roughly in half and get most of the performance benefit, without betting entirely on a newer tool for rules that matter to your project.

Verdict and migration strategy

After digging through the architecture and the practical tradeoffs, here’s where I land.

The ESLint + Prettier split is not redundancy — it’s two genuinely different tools solving two genuinely different problems. Understanding the split makes you a better engineer regardless of which tools you use. But the configuration overhead and performance cost of running them as separate pipelines is a real problem, and Biome solves it at the right level — not by gluing tools together, but by redesigning the architecture.

Situation Recommendation
New project Start with Biome. For TypeScript + React/Next.js, it covers 95%+ of what ESLint + Prettier gave you, at a fraction of the config cost.
Existing TS/React Migrate incrementally. Run biome migrate eslint + biome migrate prettier, validate, then remove the old configs over a sprint or two.
Angular project Keep ESLint with angular-eslint. Replace Prettier with Biome’s formatter only. Evaluate full migration as Biome’s Angular support matures.
Heavy plugin deps Stay on ESLint for the rules you can’t replace. Drop Prettier, use Biome as your formatter. You still win on the formatting side.
Monorepo Biome’s single-binary, single-config model is particularly compelling here. Config drift between workspaces is a common pain it directly solves.

The bigger takeaway is conceptual. For years, “use ESLint and Prettier together” was tribal knowledge — you did it because everyone else did it, not because you understood the why. Understanding that ESLint reasons about meaning and Prettier reasons about appearance — and that these require fundamentally different architectures — is the insight that makes every tooling decision from here obvious.

Biome is what happens when you start from that insight and build for it from day one, in Rust, with a unified AST and a shared parse tree. It’s not perfect yet. But the architectural foundation is correct, and the trajectory — 15 million monthly downloads, experimental Vue/Svelte/Astro support, a functioning GritQL plugin system, and a clear 2026 roadmap — makes the direction unmistakable.