Showing posts with label Python. Show all posts
Showing posts with label Python. Show all posts

Sunday, 16 August 2026

What Is Fuzzy Search? Complete Guide with Algorithms, Python Examples, and Flowcharts

Standard

 

Last week I was helping a friend wire up search on a small e-commerce site. She typed "wireles mouse" into the admin panel to test it. Exact match? Nothing. The product was listed as "Wireless Mouse, Logitech M185." Her face said it all: the search is broken.

It wasn't broken. It was just too strict.

That's the gap fuzzy search fills. You give it a messy string (typos, missing letters, swapped characters, abbreviations) and it still returns results that a human would recognize as relevant. Not magic. Just math that understands humans are sloppy typists.

What Fuzzy Search Actually Is

Regular search is binary: either the text matches or it doesn't. SQL LIKE, basic Elasticsearch term queries, Ctrl+F in your browser. All of that lives in exact-match land.

Fuzzy search assigns a similarity score between your query and each candidate string. You pick a threshold, return everything above it, and sort by score. "wireles" and "wireless" score high. "wireles" and "wrenches" score low.

The fuzzy part is the tolerance. You're not asking "is this identical?" You're asking "is this close enough that a reasonable person meant this?"

Three things usually define how it behaves:

  1. Distance metric: how you measure "closeness" (Levenshtein, Jaro-Winkler, etc.)
  2. Threshold: minimum score to count as a match
  3. Candidate set: what you're searching through (product names, user records, file paths)

Get those three wrong and you'll either miss good results or flood the user with garbage. There's no free lunch.

Algorithms People Actually Use

There's a whole zoo of string similarity algorithms. You don't need all of them. Here's what shows up in real projects.

Which Fuzzy Algorithm Should You Use?What is failing?Typo type?pick a branchmissing / wrong letterLevenshteinwireles to wirelessDistance = 1swapped keysDamerau-Levenshteinhte to the1 transpose, not 2 editsperson / company namesJaro-WinklerMARTHA vs MARHTAScore ~ 0.94partial / reordered textn-gram / trigramnight vs nigth3/4 bigrams overlapsounds alikePhonetic (Soundex)Smith to S530Smyth to S530 (match)catalog too largeBK-Tree / indexskip 90%+ of candidatesbefore scoringbuilding a text engineBitap / Shift-Orpattern in text, k errorsgrep / ripgrep styleProduction stacks often combine these: trigram pre-filter + Levenshtein/Jaro score + business rankingPick the metric for scoring; pick BK-tree / pg_trgm / Elastic for speed
Algorithm Examples at a Glance1. Levenshtein (edit distance)Count insert / delete / substitutek i t t e ns i t t i n gsubsubins gDistance = 33 edits minimum2. Damerau-Levenshtein (+ transpositions)Adjacent swap counts as 1 edit, not 2h t etot h eswap onceLevenshtein: 2Damerau: 1Also fixes:teh to the3. Jaro-Winkler (names and prefixes)Rewards matching start of stringM A R T H AM A R H T AScore ~ 0.94Great for CRM names4. n-gram / trigram overlapCompare character chunks instead of whole stringsnight:niigghhtnigth:niiggtth3 / 4 overlappg_trgm uses n=35. Phonetic (Soundex)Match by sound, not spellingSmith-> S530Smyth-> S530Same phonetic code = matchWatch out: night H knight6. Bitap / Shift-Or (pattern in text)Find pattern P inside text T with at most k errors...approximatematcning in logs...Pattern "match", k=1 findsmatcn(1 substitution)7. BK-Tree (scale, not scoring)Organize strings by distance to prune searchcatcardogQuery:"car"Visit nearby branch onlySkip "dog" subtree

Levenshtein Distance (Edit Distance)

The workhorse. Counts the minimum number of single-character edits (insert, delete, substitute) to turn string A into string B.

"kitten""sitting" needs 3 edits (k→s, e→i, append g). Distance = 3.

Simple to explain, easy to implement, well understood. Downside: it treats every character equally. A typo at the start of a long product SKU hurts the same as one in the middle, which isn't always what you want.

Time complexity: O(m × n) for strings of length m and n. Fine for short strings. Painful if you're comparing one query against a million long documents naively.

Examplewirelestowireless1 insert (s), distance 1, high match scoreDistance = 1

Damerau-Levenshtein Distance

Same as Levenshtein, but also counts transpositions (adjacent swapped chars) as a single edit.

"hte""the" = 1 edit, not 2. This matters a lot for typos. People transpose keys constantly ("teh", "recieve", "adn").

Most production fuzzy matchers I've touched either use this or Levenshtein with transposition handling baked in.

Examplehtetothetranspose h<->t = 1 editLevenshtein: 2 editsDamerau: 1 edit

Jaro and Jaro-Winkler

Jaro looks at matching characters and transpositions relative to string length. Jaro-Winkler adds a bonus when the first few characters match, which helps with names and prefixes.

"MARTHA" vs "MARHTA" scores ~0.94 with Jaro-Winkler. Good for person names, company names, anything where the beginning of the string carries more signal.

Elasticsearch's fuzzy query uses a variant of this family under the hood for short terms.

ExampleMARTHAMARHTAJaro-Winkler ~ 0.94

n-gram / Q-gram Similarity

Break strings into chunks of n characters and compare the overlap.

"night" with bigrams (n=2): ni, ig, gh, ht
"nigth" with bigrams: ni, ig, gt, th
Three out of four overlap → decent similarity despite the transposition.

Works well when word order shifts or when you're matching substrings inside longer text. PostgreSQL's pg_trgm extension uses trigrams (n=3) and it's surprisingly fast with the right index.

Example (bigrams)night:niigghhtnigth:niiggtth75% overlap

Phonetic Algorithms (Soundex, Metaphone, Double Metaphone)

These don't compare spelling. They compare how words sound.

"Smith" and "Smyth" map to similar phonetic codes. Useful for name matching in CRM systems, patient records, legacy databases where the same person was entered six different ways.

Don't use phonetic matching alone for product search. "night" and "knight" sound alike but mean different things.

Example (Soundex)Smith-> S530Smyth-> S530same codeDifferent spelling, same sound code

Bitap / Shift-Or (Approximate String Matching)

Classic algorithm for "find pattern P in text T with at most k errors." Used internally by ripgrep, GNU grep with -P, and a lot of bioinformatics tooling.

Less common in application-level search UIs, but worth knowing if you're building low-level text engines.

Example (k=1 error)log: ...matcning error...Search pattern "match" finds "matcn" (1 substitution)

BK-Trees and Metric Trees

These aren't similarity algorithms. They're data structures that make fuzzy search scalable.

A BK-tree organizes strings so that when you're looking for everything within distance k of a query, you can skip huge chunks of the tree without examining every node. Without this (or something like it), brute-force fuzzy search dies the moment your catalog crosses ~50k items.

Example (prune search)catcardogQuery "car": visitcar branch OKskip dog subtree ✗

How Fuzzy Search Flows (End to End)

Here's the pipeline I usually sketch on a whiteboard before writing code:

Fuzzy Search PipelineFrom messy query to ranked results1User types query2Normalize inputlowercase / trim / strip punctuationUnicode normalize (NFKC)3Pre-filtercandidates?YesRecommended pathTrigram / prefix / index lookupNoSlow path (prototype only)Full corpus scan+4Compute similarity scoresLevenshtein / Jaro-Winklern-gram / phonetic (optional)5Filter by threshold6Rank by score + business rulesboost exact matches firstpopularity / recency / click-through7Return top N resultsKey ideaPre-filter narrows millions of rows to hundreds before you run the expensive similarity algorithm.Skip that step and fuzzy search feels slow even when the algorithm itself is fine.Start / end stepProcessing stepDecisionHint / detail

Step by step in plain English:

  1. User types something ugly like "iphone 15 pro maxx".
  2. You normalize it: lowercase, strip extra spaces, maybe expand abbreviations.
  3. You don't compare against every row in the database (unless you're prototyping). You pre-filter: trigram index, prefix match, or an inverted index narrows candidates from 2 million to 200.
  4. Run your similarity function on those 200 candidates.
  5. Drop anything below your threshold.
  6. Re-rank: exact matches on top, then fuzzy matches, maybe weighted by sales rank or click history.
  7. Return the top 10. Done.

The pre-filter step is where most "fuzzy search is slow" complaints come from. Skip it and you'll blame the wrong algorithm.

Where You'll Actually Use This

  • E-commerce search: the obvious one. Typos, brand misspellings, "samsng tv" should still find Samsung.
  • Autocomplete / typeahead: users hammer keys fast. Fuzzy matching behind the dropdown saves a lot of "no results" dead ends.
  • Duplicate detection: merging customer records, deduplicating uploaded CSVs, finding "Jon Smith" and "John Smyth" in the same dataset.
  • Log and error search: "NullPinterException" should surface NullPointerException stack traces. DevOps folks will thank you.
  • Fuzzy command matching: CLI tools, chatbots, internal admin panels where users guess command names instead of reading docs. (I've built three of these. Nobody reads docs.)
  • Record linkage / data cleaning: government datasets, healthcare, finance. Same entity, different spellings across systems.
  • Code search: less common, but symbol fuzzy matching helps when you half-remember a function name.

Python Examples You Can Run Today

Option 1: rapidfuzz (what I'd reach for in 2026)

Fast, maintained, drop-in replacement for the older fuzzywuzzy. Written in C++ under the hood.

# pip install rapidfuzz

from rapidfuzz import fuzz, process

products = [
    "Wireless Mouse, Logitech M185",
    "Wired Keyboard, Mechanical RGB",
    "USB-C Hub 7-in-1",
    "Samsung 55-inch QLED TV",
    "iPhone 15 Pro Max 256GB",
]

query = "wireles mouse"

# Single pair comparison
score = fuzz.ratio(query.lower(), products[0].lower())
print(f"Ratio score: {score}")  # ~85+ depending on punctuation handling

# Best matches from a list
matches = process.extract(
    query,
    products,
    scorer=fuzz.WRatio,       # handles partial matches well
    score_cutoff=60,          # ignore weak matches
    limit=3,
)

for name, match_score, idx in matches:
    print(f"{match_score:5.1f}  {name}")

WRatio picks the best scoring strategy automatically (ratio, partial ratio, token sort). For product search with multi-word names, it usually outperforms plain ratio.

Option 2: Pure Python Levenshtein (no dependencies)

Good for understanding what's happening under the hood. Not what you'd ship to production at scale.

def levenshtein(a: str, b: str) -> int:
    if len(a) < len(b):
        return levenshtein(b, a)

    if not b:
        return len(a)

    prev_row = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        curr_row = [i]
        for j, cb in enumerate(b, start=1):
            insert_cost = prev_row[j] + 1
            delete_cost = curr_row[j - 1] + 1
            replace_cost = prev_row[j - 1] + (ca != cb)
            curr_row.append(min(insert_cost, delete_cost, replace_cost))
        prev_row = curr_row

    return prev_row[-1]


def similarity(a: str, b: str) -> float:
    dist = levenshtein(a.lower(), b.lower())
    max_len = max(len(a), len(b))
    return 100.0 * (1 - dist / max_len) if max_len else 100.0


candidates = ["wireless", "wireles", "wreless", "wrench", "mouse", "house"]
query = "wireles"

ranked = sorted(candidates, key=lambda w: similarity(query, w), reverse=True)
for word in ranked:
    print(f"{similarity(query, word):5.1f}  {word}")

Option 3: PostgreSQL trigrams (database-side)

If your data already lives in Postgres, enable the extension and let the database do the heavy lifting:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Index for speed (do this on columns you search often)
CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);

SELECT name, similarity(name, 'wireles mouse') AS score
FROM products
WHERE name % 'wireles mouse'   -- % operator = similarity above threshold
ORDER BY score DESC
LIMIT 10;

I've used this on a project with ~800k product rows. With the GIN index, sub-100ms queries. Without it, table scans and coffee breaks.

Option 4: Elasticsearch fuzzy query

When you're already on Elastic for full-text search, adding fuzziness is one line, but tune it carefully:

{
  "query": {
    "match": {
      "product_name": {
        "query": "samsng tv",
        "fuzziness": "AUTO",
        "prefix_length": 2
      }
    }
  }
}

prefix_length: 2 means the first two characters must match exactly. Stops "tv" from matching "tuv" and every other two-letter accident. Small detail, big difference in result quality.

Scalability: Where It Gets Hard

Brute-force fuzzy search is O(n × m × k) in the worst case: n candidates, average string length m, edit distance limit k. That math catches up fast.

Here's what actually works at scale:

1. Never scan everything

Use inverted indexes, trigram indexes (Postgres GIN, Elasticsearch n-grams), or prefix tries to cut candidates before fuzzy scoring. Target: reduce millions to hundreds, then run the expensive algorithm.

2. Set a max edit distance

Allowing distance 3 on a 4-character query ("ipod") matches almost everything. Rule of thumb I follow:

Query lengthMax edit distance
1-2 chars0 (exact only)
3-5 chars1
6+ chars2

Elasticsearch AUTO fuzziness follows similar logic. Short terms get less slack.

3. BK-trees and VP-trees for in-memory catalogs

If you're matching against a few hundred thousand strings in memory (say, a cached product catalog), a BK-tree built on Levenshtein distance prunes search space aggressively. Libraries like pybktree exist, though many teams roll a simpler trigram pre-filter instead because it's easier to reason about.

4. Batch and cache

Popular queries repeat. Cache "iphone" → top results for 5 minutes. At one retailer I worked with, the top 200 queries covered ~40% of all searches. Caching those fuzzy results dropped p95 latency noticeably.

5. Move fuzzy work offline

For duplicate detection or record linkage across millions of rows, don't do it at query time. Pre-compute candidate pairs with blocking (same first letter + same length bucket, same phonetic code, same zip code) and run fuzzy matching in a batch job.

6. Know when NOT to use fuzzy search

Semantic search ("comfortable shoes for standing all day") isn't a typo problem. That's embeddings + vector search. Different tool. I've seen teams bolt fuzzy matching onto every search field and wonder why results feel random. Match the technique to the failure mode.

Rough capacity guide from experience (not benchmarks; your mileage varies):

ApproachCorpus sizeLatency targetNotes
Naive Python loop< 1,000 itemsOK for prototypesShip something else
rapidfuzz + pre-filter10k-500ktens of msSweet spot for app-level search
Postgres pg_trgm + GIN100k-10M rows10-200 msGreat if data is already in PG
Elasticsearch fuzzy1M+ docs20-100 msNeeds cluster tuning
BK-tree in memory100k-1M stringssingle-digit msGood for dedicated matching services

Tuning Tips That Save You a Support Ticket

  • Normalize before comparing. Lowercase, Unicode normalization (NFKC), collapse whitespace. "Café" vs "cafe" shouldn't depend on whether someone typed an accent.
  • Score alone isn't enough. Boost exact matches. Penalize matches where the edit happens in the first character. "BApple" matching "Apple" is usually wrong.
  • Test with real typos. Grab a week of search logs (anonymized) and find queries with zero results. Those are your test cases. Made-up examples miss the weird stuff users actually type.
  • Watch false positives. Fuzzy search that returns "horse" for "house" erodes trust fast. Tighten threshold or require more of the query to match.
  • Measure click-through. A result that scores 72 but nobody clicks is worse than one that scores 85 and gets clicks. Business metrics beat math metrics.

Wrapping Up

Fuzzy search isn't one algorithm. It's a pipeline. Pick a similarity metric that fits your data (Levenshtein for general text, Jaro-Winkler for names, trigrams for partial matches, phonetic for spoken-alike). Pre-filter so you're not comparing against the world. Tune thresholds with real traffic, not gut feel.

My friend's e-commerce search? We added trigram pre-filtering in Postgres plus rapidfuzz for the final ranking on the top 50 candidates. "wireles mouse" found the Logitech mouse on the first try. She stopped Slack-messaging me about it, which I consider a success metric.

Start simple. Ship rapidfuzz or pg_trgm. Optimize when the profiler tells you to, not before.

References

Sunday, 21 September 2025

Serve Your Frontend via the Backend with FastAPI (and ship it on AWS Lambda)

Standard

Let's start with an example for better understanding.

If your devices are allowed to talk only to your own backend (no third-party sites), the cleanest path is to serve the UI directly from your FastAPI app  i.e. HTML, CSS, JS, and images and expose JSON endpoints under the same domain. This post shows a production-practical pattern: a static, Bootstrap-styled UI (Login → Welcome → Weather with auto-refresh) fronted entirely by FastAPI, plus a quick path to deploy on AWS Lambda.

This article builds on an example project with pages /login, /welcome, /weather, health checks, and a weather API using OpenWeatherMap, already structured for Lambda.

Why “front via backend” (a.k.a. backend-served UI)?

  • Single domain: Avoids CORS headaches, cookie confusion, and device restrictions that block third-party websites.
  • Security & control: Gate all traffic through your API (auth, rate limiting, WAF/CDN).
  • Simplicity: One deployable artifact, one CDN/domain, one set of logs.
  • Edge caching: Cache static assets while keeping API dynamic.

Minimal project layout

fastAPIstaticpage/
├── main.py                 # FastAPI app
├── lambda_handler.py       # Mangum/handler for Lambda
├── requirements.txt
├── static/
│   ├── css/style.css
│   ├── js/login.js
│   ├── js/welcome.js
│   ├── js/weather.js
│   ├── login.html
│   ├── welcome.html
│   └── weather.html
└── (serverless.yml or template.yaml, deploy.sh)

The static directory holds your UI; FastAPI serves those files and exposes API routes like /api/login, /api/welcome, /api/weather.

FastAPI: serve pages + APIs from one app

1) Boot the app and mount static files

# main.py
import os, httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles

OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY")

app = FastAPI(title="Frontend via Backend with FastAPI")

# Serve everything under /static (CSS/JS/Images/HTML)
app.mount("/static", StaticFiles(directory="static"), name="static")

# Optionally make pretty routes for pages:
@app.get("/", include_in_schema=False)
@app.get("/login", include_in_schema=False)
def login_page():
    return FileResponse("static/login.html")

@app.get("/welcome", include_in_schema=False)
def welcome_page():
    return FileResponse("static/welcome.html")

@app.get("/weather", include_in_schema=False)
def weather_page():
    return FileResponse("static/weather.html")

Tip: If you prefer templating (Jinja2) over plain HTML files, use from fastapi.templating import Jinja2Templates and render context from the server. For pure static HTML + fetch() calls, FileResponse is perfect.

2) JSON endpoints that the UI calls

@app.post("/api/login")
async def login(payload: dict):
    email = payload.get("email")
    password = payload.get("password")
    # Demo only: replace with proper auth in production
    if email == "admin" and password == "admin":
        return {"ok": True, "user": {"email": email}}
    raise HTTPException(status_code=401, detail="Invalid credentials")

@app.get("/api/welcome")
async def welcome():
    # In real apps, read user/session; here we return a demo message
    return {"message": "Welcome back, Admin!"}

@app.get("/api/weather")
async def weather(city: str = "Bengaluru", units: str = "metric"):
    if not OPENWEATHER_API_KEY:
        raise HTTPException(500, "OPENWEATHER_API_KEY missing")
    url = "https://api.openweathermap.org/data/2.5/weather"
    params = {"q": city, "appid": OPENWEATHER_API_KEY, "units": units}
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.get(url, params=params)
    if r.status_code != 200:
        raise HTTPException(r.status_code, "Weather API error")
    return r.json()

@app.get("/health", include_in_schema=False)
def health():
    return {"status": "ok"}

The pages: keep HTML static, fetch data with JS

static/login.html (snippet)

<form id="loginForm">
  <input name="email" placeholder="email" />
  <input name="password" type="password" placeholder="password" />
  <button type="submit">Sign in</button>
</form>
<script src="/static/js/login.js"></script>

static/js/login.js (snippet)

document.getElementById("loginForm").addEventListener("submit", async (e) => {
  e.preventDefault();
  const form = new FormData(e.target);
  const res = await fetch("/api/login", {
    method: "POST",
    headers: {"Content-Type":"application/json"},
    body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
  });
  if (res.ok) location.href = "/welcome";
  else alert("Invalid credentials");
});

static/weather.html (snippet)

<div>
  <h2>Weather</h2>
  <select id="city">
    <option>Bengaluru</option><option>Mumbai</option><option>Delhi</option>
  </select>
  <pre id="result">Loading...</pre>
</div>
<script src="/static/js/weather.js"></script>

static/js/weather.js (snippet, 10s auto-refresh)

async function load() {
  const city = document.getElementById("city").value;
  const r = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
  document.getElementById("result").textContent = JSON.stringify(await r.json(), null, 2);
}
document.getElementById("city").addEventListener("change", load);
load();
setInterval(load, 10_000); // auto-refresh every 10s

The example app in the attached README uses the same flow: / (login) → /welcome/weather, with Bootstrap UI and a 10-second weather refresh.

Shipping it on AWS Lambda (two quick options)

You can deploy the exact same app to Lambda behind API Gateway.

Option A: SAM (recommended for many teams)

  1. Add template.yaml and run:
sam build
sam deploy --guided
  1. Point a domain (Route 53) + CloudFront if needed for caching static assets.
    (These steps mirror the attached project scaffolding.)

Option B: Serverless Framework

npm i -g serverless serverless-python-requirements
serverless deploy

Both approaches package your FastAPI app for Lambda. If you prefer a single entrypoint, use Mangum:

# lambda_handler.py
from mangum import Mangum
from main import app

handler = Mangum(app)

Pro tip: set appropriate cache headers for /static/* and no-cache for JSON endpoints.

Production hardening checklist

  • Auth: Replace demo creds with JWT/session, store secrets in AWS Secrets Manager.
  • HTTPS only: Enforce TLS; set Secure, HttpOnly, SameSite on cookies if used.
  • Headers: Add CSP, X-Frame-Options, Referrer-Policy, etc. via a middleware.
  • CORS: Usually unnecessary when UI and API share the same domain—keep it off by default.
  • Rate limits/WAF: Use API Gateway/WAF; some CDNs block requests lacking User-Agent.
  • Observability: Push logs/metrics to CloudWatch; add /health and structured logs.
  • Performance: Cache static assets at CloudFront; compress; fingerprint files (e.g., app.abc123.js).

Architecture Diagram


This diagram illustrates how a single-origin architecture works when serving both frontend (HTML, CSS, JS) and backend (API) traffic through FastAPI running on AWS Lambda.

Flow of Requests

1. User / Device

    • The client (e.g., browser, in-vehicle device, mobile app) makes a request to your app domain.

2. CloudFront

    • Acts as a Content Delivery Network (CDN) and TLS termination point.
    • Provides caching, DDoS protection, and performance optimization.
    • All requests are routed through CloudFront.

3. API Gateway

    • CloudFront forwards the request to Amazon API Gateway.
    • API Gateway handles routing, throttling, authentication (if configured), and request validation.
    • All paths (/, /login, /welcome, /weather, /api/...) pass through here.

4. Lambda (FastAPI)

    • API Gateway invokes the AWS Lambda function running FastAPI (using Mangum).
    • This single app serves:

  • Static content (HTML/CSS/JS bundled with the Lambda package or EFS)
  • API responses (login, weather, welcome, etc.)

Supporting Components

1. Local / EFS / In-package static

  • Your frontend files (e.g., login.html, weather.html, JS bundles) are either packaged inside the Lambda zip, stored in EFS, or mounted locally.
  • This allows the FastAPI app to return HTML/JS without needing a separate S3 bucket.

2. Observability & Secrets

  • CloudWatch Logs & Metrics capture all Lambda and API Gateway activity (for debugging, monitoring, and alerting).
  • Secrets Manager stores sensitive data (e.g., OpenWeatherMap API key, DB credentials). Lambda retrieves these securely at runtime.

Why This Architecture ?

  • One origin (no separate frontend on S3), meaning devices only talk to your backend domain.
  • No CORS needed because UI and API share the same domain.
  • Tight control over auth, caching, and delivery.
  • Ideal when working with restricted environments (e.g., in-vehicle browsers or IoT devices).

When this architecture shines (and is cost-efficient) ?

Devices must hit only your domain

  • In-vehicle browsers, kiosk/IVI, corporate-locked devices.
  • You serve HTML/CSS/JS and APIs from one origin → no CORS, simpler auth, tighter control.

Low-to-medium, spiky traffic (pay-per-use wins)

  • Nights/weekends idle, bursts during the day or at launches.
  • Lambda scales to zero; you don’t pay for idle EC2/ECS.

Small/medium static assets bundled or EFS-hosted

  • App shell HTML + a few JS/CSS files (tens of KBs → a few MBs).
  • CloudFront caches most hits; Lambda mostly executes on first cache miss.

Simple global delivery needs

  • CloudFront gives TLS, caching, DDoS mitigation, and global POPs with almost no ops.

Tight teams / fast iteration

  • One repo, one deployment path (SAM/Serverless).
  • Great for prototypes → pilot → production without re-architecting.

Traffic & cost heuristics (rules of thumb)

Use these to sanity-check costs; they’re order-of-magnitude, excluding data transfer:

Lambda is cheapest when:

  • Average load is bursty and < a few million requests/month, and
  • Per-request work is modest (sub-second, 128–512 MB memory), and
  • Static assets are cache-friendly (CloudFront hit ratio high).

Rough mental math (how to approximate)

  • Per-request Lambda cost ≈ (memory GB) × (duration sec) × (price per GB-s) + (request charge).
  • Example shape (not exact pricing): at 256 MB and 200 ms, compute cost per 100k requests is typically pennies to low dollars; the bigger bill tends to be egress/data transfer if your assets are large.
  • CloudFront greatly reduces Lambda invocations for static paths (high cache hit ratio → far fewer Lambda runs).

If your bill is mostly data (images, big JS bundles, downloads), move those to S3 + CloudFront (dual-origin). It’s almost always cheaper for heavy static.

Perfect fits (based on real-world patterns)

  • In-vehicle Apps with Web view : UI must come from your backend only; traffic is intermittent; pages are light; auth and policies live at the edge/API Gateway.
  • Internal tools, admin consoles, partner portals with uneven usage.
  • Geo-gated or compliance-gated UIs where a single origin simplifies policy.
  • Early-stage products and pilots where you want minimal ops and fast changes.

When to switch (or start with a dual-origin) ?

  • Front-heavy sites (lots of images/video, large JS bundles)
Use S3 + CloudFront for /static/* and keep Lambda for /api/*.
Same domain via CloudFront behaviors → still no CORS.

  • High, steady traffic (always busy)
If you’re sustaining high RPS all day, Fargate/ECS/EC2 behind ALB can beat Lambda on cost and cold-start latency.

  • Very low latency or long-lived connections
Ultra-low p95 targets, or WebSockets with heavy fan-out → consider ECS/EKS or API Gateway WebSockets with tailored design.

  • Heavy CPU/GPU per request (ML inference, large PDFs, video processing)

Dedicated containers/instances (ECS/EKS/EC2) with right sizing are usually cheaper and faster.

Simple decision tree

Do you need single origin + locked-down devices?
    Yes → Single-origin Lambda is great.

Are your static assets > a few MB and dominate traffic?
    Yes → Dual-origin (S3 for static + Lambda for API).

Is traffic high and steady (e.g., >5–10M req/mo with sub-second work)?
    Consider ECS/Fargate for cost predictability.

Do you need near-zero cold-start latency?
    Prefer containers or keep Lambda warm (provisioned concurrency → raises cost).

Cost-saving tips (keep Lambda, cut the bill)

  • Cache hard at CloudFront: long TTLs for /static/*, hashed filenames; no-cache for /api/*.
  • Slim assets: compress, tree-shake, code-split, use HTTP/2.
  • Right-size Lambda memory: test 128/256/512 MB; pick the best $/latency.
  • Warm paths (if needed): provisioned concurrency only on critical API stages/times.
  • Move heavy static to S3 while keeping single domain via CloudFront behaviors.

Bottom line

  • If your devices can only call your backend, traffic is bursty/medium, and your frontend is lightweight, this Lambda + API Gateway + CloudFront single-origin setup is both operationally simple and cost-efficient.
  • As static volume or steady traffic grows, go dual-origin (S3 + Lambda) first; if traffic becomes large and constant or latency targets tighten, move APIs to containers.

Bibliography