AI will keep React alive forever. That should terrify you.

Whenever you tell an AI, “build me a login page,” it writes React. Not Svelte. Not Solid. Not Vue. React. And that default is unobtrusively but strongly shaping the future of the web as a whole.

I’ve been mulling this over since I came across the Dead Framework Theory. The argument is simple and uncomfortable: LLMs are trained on massive amounts of existing code, and React dominates that training data by a landslide. AI generates React code. Humans integrate that React code and ship it. It’s now new code for the model to learn from. And so on.

This situation is like a feedback loop that nobody constructed a way out of.

The loop that feeds itself

Here’s how it works in simple terms.

→ React has the most tutorials, the most Stack Overflow answers, the most open-source repos.
→ LLMs absorb all of that and learn that “web app” basically means “React app.”
→ New developers use AI to scaffold projects and get React by default.
→ Those projects produce more React code on the public internet.
→ The next generation of models trains on that code.

Each cycle tightens the grip. It’s not a conspiracy. It’s just math. The biggest corpus wins the autocompletion lottery.

Why this should bother you

I don’t have anything against React. I’ve used it plenty. But a monoculture is dangerous in any ecosystem.

The way Svelte’s compiler-first approach truly stands out. The real problems Solid’s fine-grained reactivity solves are still problems React just doesn’t address. These aren’t play things — they’re real innovation in how we conceptualize rendering and state. If AI-assisted coding silently starves them of potential users, we all lose.

It’s not alarming that React has become popular. What is alarming though is that this popularity is now being reinforced by machines, in a manner that has never existed in the past. Human hype cycles eventually cool down. But this may not.

“Just fine-tune the models”

The obvious counterargument: we can fine-tune LLMs on Svelte or Solid codebases and fix the bias. And actual business applications written in the non-canonical implementation aren’t irrelevant. Critics of the Dead Framework Theory say exactly this.

However, fine-tuning is expensive, time-consuming, and requires continuous investment. Who would pay for it? The framework authors who are already struggling with their open-source sustainability? Should the Svelte community come up with the money to fine-tune a model that would still be in direct competition with React’s “organically” obtained data model?

It’s possible in theory. In practice, the incentives don’t line up. Companies building AI coding tools optimize for the largest user base. That’s React developers. So the default stays React. The loop continues.

The real question is about defaults

Default settings have a strong influence on software. The majority of developers stick with them. Most AI-generated code gets accepted with minor edits, not rewritten from scratch.

When React is the default output of any AI coding tool, React stops only being the most popular framework. It becomes an infrastructural assumption of AI-assisted web development. That’s a different kind of lock-in than “a lot of people use and like this.”

I think we’re sleepwalking into a world where framework choice is an illusion. Technically, you can choose differently. But your AI tools will fight you every step of the way. Your junior devs will have never seen anything else. Your hiring pipeline will filter for React because that’s what candidates learned — from AI.

So what do we do?

To be honest, I can’t give you a definitive answer on that. However, I believe that AI tooling, which is aware of the framework you are using and prompts you to select it before generating any code, is important. Also, I think that anyone developing coding assistance tools should consider training data diversity as a primary issue.

And I think we need to talk about this now, before the loop gets another two or three training cycles deeper.

What do you think – is AI-driven framework lock-in a serious problem, or will things work out in the end? I’d especially love to hear from folks working on non-React frameworks. Is the pain already setting in? Drop your comments below! 👇

Localizing a Tauri App for Japanese and English — What Actually Works

All tests run on an 8-year-old MacBook Air.
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.
All my apps ship in Japanese and English. Not separate codebases — one app, two languages, runtime switching.
Here’s the implementation that works in production.

Why bother with dual language
Japanese users convert better on Japanese UI with JPY pricing. English users are a global market. Both are worth serving. One codebase is the only viable approach for a solo developer.

The i18n library
I use react-i18next in the React frontend:
bashnpm install i18next react-i18next
typescript// i18n.ts
import i18n from ‘i18next’
import { initReactI18next } from ‘react-i18next’

i18n.use(initReactI18next).init({
resources: {
en: { translation: enTranslations },
ja: { translation: jaTranslations },
},
lng: ‘en’,
fallbackLng: ‘en’,
})

export default i18n

Translation file structure
typescript// en.json
{
“sync”: {
“start”: “Start Sync”,
“stop”: “Stop Sync”,
“status”: {
“idle”: “Ready”,
“running”: “Syncing…”,
“complete”: “Sync complete”,
“error”: “Sync failed”
}
},
“device”: {
“connect”: “Connect Device”,
“disconnect”: “Disconnect”,
“notFound”: “No device found”
}
}

// ja.json
{
“sync”: {
“start”: “同期開始”,
“stop”: “同期停止”,
“status”: {
“idle”: “待機中”,
“running”: “同期中…”,
“complete”: “同期完了”,
“error”: “同期エラー”
}
},
“device”: {
“connect”: “デバイスを接続”,
“disconnect”: “切断”,
“notFound”: “デバイスが見つかりません”
}
}

Detecting system language
Get the system locale from Rust and pass to the frontend on launch:
rust#[tauri::command]
fn get_system_locale() -> String {
std::env::var(“LANG”)
.unwrap_or_default()
.split(‘.’)
.next()
.unwrap_or(“en”)
.to_string()
}
typescriptconst locale = await invoke(‘get_system_locale’)
const lang = locale.startsWith(‘ja’) ? ‘ja’ : ‘en’
i18n.changeLanguage(lang)

Localizing Gemini prompts
AI prompts need localization too. Japanese users get Japanese responses:
rustpub fn build_prompt(input: &str, lang: &str) -> String {
match lang {
“ja” => format!(
“以下のエラーを日本語で説明し、修正方法を提案してください。nエラー: {}”,
input
),
_ => format!(
“Explain this error and suggest a fix.nError: {}”,
input
),
}
}
A Japanese user who gets an English AI response will be confused. Localize the prompts.

The separate Gumroad listings
One app binary, two Gumroad listings — JP and EN with different prices and descriptions. The JP listing uses JPY pricing. The EN listing uses USD.
Users self-select. The binary is identical. The storefront is localized.

The verdict
react-i18next + system locale detection + localized AI prompts covers the full localization stack. The overhead is low. The market expansion is real.

If this was useful, a ❤️ helps more than you’d think — thanks!
Hiyoko PDF Vault → https://hiyokoko.gumroad.com/l/HiyokoPDFVault
X → @hiyoyok

Why Your AI-Generated Code Passes Tests but Fails Production

AI coding assistants are fast — but fast in the wrong direction is just a faster way to fail. Here’s how specs and BDD scenarios turn AI into a reliable executor, not a fast guesser.

The views and opinions expressed in this article are solely our own and do not represent those of our employer, Amazon, or any of its affiliates. All content is based on publicly available information and our personal experience. Any references to products or tools are based solely on publicly available information. No confidential or proprietary information has been disclosed.

AI coding assistants have changed the economics of software development. A well-prompted AI can produce in an hour what used to take a day. Teams are shipping faster than ever. And a growing number of those teams are shipping the wrong thing faster than ever.

The problem isn’t the AI. The problem is what you give it to work with.

AI Is Only as Good as Its Context

When you ask an AI to implement a feature with a vague prompt, it does exactly what you’d expect — it makes reasonable assumptions, fills in the gaps with general software patterns, and produces code that looks correct, compiles cleanly, and is wrong for your domain in ways that won’t surface until production.

This isn’t a flaw in the AI. It’s a structural reality. AI generates code based on pattern recognition. It doesn’t know your business rules. It doesn’t know that a missing rent estimate should return a score with confidence: "low" instead of throwing an error. It doesn’t know that negative cash flow shouldn’t automatically fail a deal — it should reduce the score by a maximum of 30 points. It fills those gaps with the most statistically likely answer, which is plausible, passes a surface-level review, and violates your domain logic.

The solution isn’t better prompting. It’s giving the AI a contract to implement against.

That contract is a spec. The mechanism that enforces it is a BDD scenario suite. Together, they transform AI from a fast guesser into a fast executor — and that distinction is everything.

The Contract: Spec-Driven Development

A spec is not a PRD. It’s not a Jira ticket. It’s a precise, unambiguous description of what a system must do — inputs, outputs, rules, edge cases, error states — written before any code exists.

When an AI reads a spec, it stops guessing. Every rule is explicit. Every edge case is named. The AI has no gaps to fill because the spec filled them first.

Feature: Deal Score Calculation
  Input: property price, rent estimate, assumptions (rate, down payment, taxes)
  Output: score 0–100, grade A–F, confidence level

  Rules:
  - Grade maps: 80+ → A, 65–79 → B, 50–64 → C, 35–49 → D, <35 → F
  - Missing rent estimate → score still computed, confidence: "low"
  - Negative cash flow reduces score by max 30 points, not automatic failure
  - Price above $800k → flag "high price tier", no score penalty

Compare what an AI produces with and without this spec:

Prompt without spec: "implement deal score calculation"

AI output:
- Returns 0 for missing rent estimate          ← wrong
- Fails immediately on negative cash flow      ← wrong
- No price tier flagging                       ← missing
- Passes all generic unit tests                ← tests share the same blind spots
Prompt with spec: "implement deal score calculation per this spec: [spec above]"

AI output:
- Handles missing rent estimate, returns confidence: "low"   ✓
- Caps cash flow penalty at 30 points                        ✓
- Flags high price tier without penalizing score             ✓
- Grade mapping matches spec exactly                         ✓

Same AI. Same tool. The spec is the only variable. The spec didn’t slow the AI down — it directed it. The AI’s speed is now working for you instead of against you.

The Guardrail: BDD Scenarios

The spec tells the AI what to build. BDD scenarios tell the AI — and your CI pipeline — whether it built it correctly.

BDD scenarios are written in plain language (typically Gherkin) that describes system behavior from the outside. They are independent of implementation. The AI cannot game them by writing tests that match its own code, because they were written from the spec before the code existed.

Scenario: Missing rent estimate
  Given a property priced at $450,000
  And no rent estimate is available
  When the deal score is calculated
  Then a score is still returned
  And the confidence level is "low"

Scenario: Negative cash flow
  Given a property with negative monthly cash flow of -$400
  When the deal score is calculated
  Then the score is reduced by no more than 30 points
  And the deal is not automatically failed

Scenario: High price tier
  Given a property priced at $850,000
  And a monthly rent estimate of $4,500
  When the deal score is calculated
  Then the "high price tier" flag is present
  And the score is not penalized for price alone

When you give an AI these scenarios alongside the spec, it has a verifiable definition of done. It can run the scenarios against its own output, catch failures, and self-correct before a human ever sees the code. The scenarios are not just tests — they are the AI’s feedback loop.

This is why BDD scenarios are more valuable in an AI-driven workflow than traditional unit tests. Unit tests written by AI share the same assumptions as the code AI writes. BDD scenarios written from the spec are assumption-free. They describe behavior, not internals.

AI as a Spec Stress-Tester

The relationship between AI and specs isn’t one-directional. AI is also one of the most effective tools for finding gaps in a spec before implementation begins.

Give an AI your spec and ask it to break it:

Prompt: "Given this spec, what edge cases are missing?

Rules:
- Score is 0–100
- Missing rent estimate → confidence: 'low'
- Negative cash flow reduces score by max 30 points"

AI response:
- What if both rent estimate AND cash flow data are missing simultaneously?
- What if price is $0 or negative — validation error or score of 0?
- What if cash flow is exactly $0 — is that negative or neutral?
- What if compounding penalties push the score below 0?
- Is there a maximum rent estimate that should be flagged as suspicious input?

Every one of those questions is a potential production bug. Answered in the spec, they cost nothing. Discovered after deployment, they cost a sprint, a hotfix, and a post-mortem.> AI-assisted spec review is the highest-leverage use of AI in the entire development cycle — because it eliminates bugs before a single line of code is written.

The Full AI-Driven Workflow

Here’s what a feature cycle looks like when AI, specs, and BDD scenarios work together:

Day 1 — Spec authored by humans, stress-tested by AI
  → Tech lead, PM, and senior engineer write the spec
  → AI is asked: "what edge cases are missing from this spec?"
  → Gaps are resolved before implementation begins
  → Spec committed to the repo alongside the code it governs

Day 2 — Scenarios written from the spec
  → Engineers and QA convert spec rules into BDD scenarios
  → AI generates adversarial scenarios: "write scenarios that would
     catch a naive implementation"
  → Every rule has at least one scenario; every edge case has one too

Day 3 — AI implements against spec + scenarios
  → AI is given spec and scenarios as context
  → AI implements to pass scenarios, not to satisfy a vague description
  → AI runs scenarios locally, self-corrects, opens PR with green scenarios

Day 4 — Human review, focused on architecture
  → Reviewer sees passing scenarios — behavioral correctness is already verified
  → Review focuses on approach, performance, and maintainability
  → Review is faster because the question "does this do the right thing?" 
     is already answered

Day 5 — CI enforces the contract forever
  → Scenarios run on every commit
  → Any future AI-generated change that breaks behavior fails the build
  → The spec is permanently enforced without anyone having to remember it

The human’s role in this workflow shifts toward high-judgment work: writing specs, reviewing architecture, resolving edge cases. The high-volume work — implementation, test generation, gap analysis — goes to AI. That’s the right division of labor.

Why AI-Generated Tests Are Not Enough

A common mistake teams make when adopting AI tools is asking the AI to write tests alongside the implementation. This feels productive. It produces coverage numbers. It doesn’t protect you.

AI-generated tests share the same assumptions as AI-generated code. If the AI misunderstood the spec (or there was no spec), the tests will validate the wrong behavior confidently. You end up with a green test suite that proves the code does what the AI thought you wanted, not what you actually needed.

BDD scenarios written from the spec before implementation are the only tests that are truly independent of the AI’s assumptions. They describe what the system must do from the outside. The AI cannot write them correctly without the spec — and if it tries, the gaps in the spec become immediately visible.

// AI-generated unit test (no spec):
it('returns 0 when rent estimate is missing')  // ← validates wrong behavior
# BDD scenario (from spec):
Given no rent estimate is available
When the deal score is calculated
Then a score is still returned        # ← catches the wrong behavior
And the confidence level is "low"

The BDD scenario catches exactly what the AI-generated test missed. That’s not a coincidence — it’s the structural difference between tests that describe implementation and tests that describe behavior.

The Compounding Advantage

The real payoff of this approach isn’t in a single feature cycle. It’s what happens to the codebase over time.

Every feature built with a spec and BDD scenarios adds a permanent behavioral contract to the codebase. Six months later, when an AI is asked to refactor the scoring engine, the scenarios tell it immediately whether the behavior survived. The AI doesn’t need to read the original spec, find the original engineer, or guess at intent. It runs the scenarios. They pass or they don’t.

This is the compounding advantage: every spec and scenario written today makes every future AI interaction with that code safer and faster. The codebase becomes a set of contracts that AI can execute against with confidence, rather than a codebase it has to interpret and guess at.

Teams that build this way are not just shipping faster today. They are making every future sprint faster too.

The Practical Setup

The tooling to run this workflow end to end exists today:

Spec files: Markdown committed to the repo, co-located with the code they govern. AI reads them as context on every implementation prompt.

Scenario files: Gherkin syntax, run by Cucumber (JS/Java/Ruby), pytest-bdd (Python), or SpecFlow (.NET). Committed to the repo, run in CI on every commit.

AI tools: Claude Code, GitHub Copilot, Cursor — any assistant that accepts file context. The spec and scenario files are the context. Quality of output scales directly with quality of spec.

CI enforcement: GitHub Actions, AWS CodePipeline, or any CI system. Failing scenario blocks the merge. No exceptions, no overrides.

The spec and scenarios are not documentation overhead. They are the interface between human intent and AI execution. Remove them and AI is fast and unpredictable. Keep them and AI is fast and contract-bound.

The Rule That Makes It Work

One rule, enforced without exception:

🚫 No scenario, no code.

If a behavior isn’t described in a scenario, it doesn’t get implemented — by a human or an AI. If the AI wants to handle a case the spec didn’t cover, the scenario gets written first. That either updates the spec or surfaces a disagreement that gets resolved before the code exists.

This rule feels like friction. It is friction — once, upfront, where it costs almost nothing. It eliminates the friction that compounds: the rework, the regression, the hotfix, the post-mortem, the sprint lost to “that’s not what I meant.”

Closing

AI coding tools are not going to slow down. The teams that figure out how to direct them precisely will compound their advantage over the teams that are still prompting vaguely and reviewing hopefully.

The spec is the precision. The BDD scenarios are the enforcement. Together they give AI exactly what it needs to stop guessing and start executing — against a contract that humans defined, scenarios verify, and CI protects on every commit forever.

Fast AI with no contract is a liability. Fast AI with a contract is a force multiplier.

Write the spec first. Write the scenarios second. Let the AI do the rest.

About the Authors

**Written by Ashwini Kumar and Malcolm Jones — Software Engineers @ Amazon.

The views and opinions expressed in this article are solely our own and do not represent those of our employer, Amazon, or any of its affiliates.

Model Showdown Round 5: Four Agents Build the Same Feature

I’ve been running model showdowns on Vibes Coder for a while now. Each round has been a little messier than I wanted — different prompts, accidental context leaks, no clean way to compare cost to quality. This one is the first I’d call a fair bakeoff. Two goals going in:

  1. Make the experiment itself rigorous enough that future rounds can build on it — isolated chat sessions, identical prompts, anonymized branches, blind judging, real token + runtime data pulled from the Coder API.
  2. Compare three flavors of Claude against our local champ. Opus 4.7, Opus 4.6, and Sonnet 4.6 from Anthropic; Qwen 3.5 35B-A3B running on llama.cpp on the RTX 5090 in the home lab. Four models, same task, four isolated Coder Agents sessions, blind judging.

The headline: Sonnet 4.6 beat Opus 4.6 on a coding task. Not by much (4.48 vs 4.36) but cleanly, on its own merits, with no asterisks. And once I pulled real token and runtime data from Coder’s chat-cost API, a second headline emerged: weighted by cost, Sonnet’s win becomes decisive — about 10x cheaper per rubric point than either Opus model. A third wrinkle: Opus 4.7 finished the task in 9.2 minutes, the fastest of the three Claude runs. It won the rubric without burning the most time. The deeper story is what each model did with the same prompt, and what it took to make the bakeoff fair in the first place — which turned out to be more work than the bakeoff itself.

The Setup

The contestants:

Run Model Where it runs
1 Claude Opus 4.7 Cloud, via Coder Agents
2 Claude Sonnet 4.6 Cloud, via Coder Agents
3 Claude Opus 4.6 Cloud, via Coder Agents
4 Qwen 3.5 35B-A3B Local, llama.cpp on the RTX 5090, via Coder Agents

The mapping was private. Branches were named run-1 through run-4. I judged the four branches blind against a fixed rubric, then revealed the identities.

The task: build image management into the vibescoder.dev admin dashboard. The current /admin page has a Settings card that’s a placeholder. The spec asked for an Images card (or a replacement) that lists the post-image directories under public/images/, detects orphans (directories with no matching post), provides a screenshot view, and adds an API route to delete a directory.

It’s not a huge feature, but it has enough surface area to differentiate models: filesystem traversal, slug matching, path validation, an API contract with a destructive verb, a UI page, and at least one judgment call (what counts as an “orphan?”).

The fairness story

Before launching anything, three things needed fixing. None of them are interesting on their own. Together they’re the operational lesson of this post: a bakeoff isn’t fair by default.

Fix 1: Node 18 vs Node 20

The workspace image is built on Ubuntu 24.04. Ubuntu 24.04’s apt Node is 18.19. Next.js 16 — what the blog engine ships on — requires Node 20+. Any agent that ran apt install nodejs would silently break its own build.

The fix was a Dockerfile change in the coder-templates repo: install Node 20 from NodeSource at image build time, pin npm, verify node -v reports 20.x in the smoke test. After that, node -v in a fresh workspace prints v20.20.2 and nothing the agents do (short of nvm shenanigans) changes that.

Fix 2: The system instructions were lying

The chat system prompt — injected at the top of every Coder Agents session — said Node was not pre-installed and told agents to install it themselves. Correct on the previous image; actively misleading after Fix 1. An agent following the instructions would apt install nodejs, get Node 18, downgrade the runtime, and break the build.

I rewrote the instructions to say Node 20 is pre-installed, do not reinstall, use nvm if you need a different version. Boring change. Huge impact on whether the bakeoff produces meaningful signal.

Fix 3: Prompt poisoning

The first draft of the bakeoff prompt told each agent to create a branch named after the model running the session — bakeoff-opus47, bakeoff-sonnet46, and so on. A sharp catch from the human side: that wording leaks competition signaling into the prompt. An agent that sees “you are opus47” or even “this is a bakeoff” can adjust behavior in ways that aren’t comparable. The experiment stops measuring “what does this model do with the prompt” and starts measuring “what does this model do when it knows it’s on stage.”

Fix: replace model names with neutral ordinals. Branches became run-1 through run-4. The prompt made no reference to other runs, scoring, or any comparison. Each agent thought it was building a feature, not auditioning.

Three small fixes. Together they’re the operational lesson: fairness in a model bakeoff requires more setup than the bakeoff itself.

The prompt

The prompt was identical for all four runs, save for the run number in the branch name. Verbatim, with one path generalized:

You are working in the vibescoder.dev blog engine repo. Branch: run-N.
Baseline commit is at the tip of main.

Goal: add image management to /admin.

Requirements:
- List the directories under public/images/ (each directory corresponds
  to one post and contains its images).
- For each directory, report: name, file count, total size on disk,
  and whether it matches a published or draft post (by slug).
- Surface "orphaned" directories — directories that do not match any
  post — so I can clean them up.
- Provide a way to view the images in a directory (thumbnails or list).
- Provide an API route DELETE /api/admin/images that removes a
  directory by path. The route must validate input.
- Update the /admin landing page so the new feature is reachable.
  You may keep the Settings placeholder card or replace it; either is fine.
- Add a screenshot of the new page to the PR description (use the
  Playwright MCP).
- Run `npm run build` before committing. Do not push commits that
  fail the build.
- Commit in logical chunks. Push the branch when done.

That’s it. No mention of competing runs. No scoring rubric. No model identification. Just a feature spec and a quality bar.

The four implementations

All four runs built it. All four passed npm run build against a shared engine baseline on Node 20.20.2. All four pushed their branches. Then the differences started showing up.

Run 1 — 8 new files, 631+/9-

Replaced the Settings placeholder with an Images card on /admin. Added a dedicated /admin/images page that lists directories server-side, plus a client-side modal that renders a grid of thumbnails when you click into a directory. Three screenshots in the PR description — admin landing, images list, modal open with orphan-flagged styling.

The standout was the API route. Run 1 wrote a real path validator — isValidImageRepoPath — that required exactly two path segments under public/images/, rejected .., and ran before the filesystem call. The route returned distinct status codes for distinct failure modes: 400 for bad input, 404 for missing, 403 for paths that resolve outside the allowed root, 200 for success.

It’s not glamorous code. It’s just the version where someone thought about the failure modes before writing the success path.

Run 1 admin/images page

Run 1’s /admin/images page. Directory cards, orphan-flagged styling, and a tight path-validated delete API behind the trash icons.

Run 2 — 6 new files, 687+/7-

Kept the Settings card. Added an Images card next to it on /admin. The /admin/images page was the cleanest of the four — tight TypeScript, no as casts in the API route, proper type narrowing (typeof body === "object" && "path" in body) instead of forcing the compiler to trust it. The UI had the most visual polish: directory cards with file counts as a badge, hover states that matched the rest of the admin surface, a confirmation modal on delete that quoted the directory name back at you.

Path validation was decent but not as rigorous as Run 1 — startsWith("public/images/") plus a .. block, no segment-count check. Enough to stop the obvious cases. Not airtight against creative inputs.

Two screenshots. Shipped a polished v1 and stopped.

Run 2 admin/images page

Run 2 kept the Settings card and put Images next to it. Cleanest TypeScript of the four; smallest screenshot artifact.

Run 3 — 6 new files, 595+/0-

Replaced the Settings placeholder. The /admin/images page started as a server component, then mid-task switched to a client-fetched implementation when Run 3 hit a dev-server timeout on the first integration test. That mid-stream pivot showed up cleanly in the commit history — feat: add admin/images server-rendered, then two commits later, refactor: move admin/images to client fetch (dev server hangs on FS scan).

Path validation matched Run 2’s. The thing that made Run 3 interesting was the orphan-detection arc.

The spec said “match directory name against post slugs to find orphans.” Three of the four models took that literally — list directories, list slugs, set-difference, report what’s left. Run 3 did that first, reported 8 orphaned directories, then checked the result against reality. Looked at the actual file tree and noticed that one of the “orphaned” directories was day-four/, and there’s a published post with the slug day-four-rss-analytics-syndication-and-loom. The directory isn’t orphaned. It belongs to that post. The matching logic was wrong.

Run 3 iterated three times: exact match → prefix match (does any slug start with this directory name?) → content-reference match (does any post body reference an image in this directory?). After the third pass, the orphan count went from 8 to 1 — and the one remaining was an actual orphan I’d been meaning to delete for weeks.

Small thing in the diff. Big thing in engineering judgment. The other three models reported false-positive orphans with high confidence. Run 3 noticed its own answer was wrong and kept working.

Run 3 admin/images page

Run 3’s screenshot — the largest and most polished of the four. The orphan count in the header reads 1 instead of 8 because the matching logic had been corrected mid-task.

Run 4 — 7 new files, 607+/0-

Kept the Settings card, added an Images card. The /admin/images page worked. Build passed. The directory listing rendered correctly.

Two structural issues. First, the codebase ended up with two utility libraries — images.ts and imageUtils.ts — with overlapping responsibilities. The first pass put filesystem helpers in images.ts, which got imported into a client component, which pulled fs into the client bundle and broke the build. The fix added imageUtils.ts for client-safe helpers and re-imported. The dead code in images.ts was never cleaned up.

Second, the screenshot. Run 4 ran playwright screenshot, hit the same missing-system-libraries failure the other three runs hit (libnspr4, libpango-1.0-0, the headless Chromium kit), sudo apt install-ed the dependencies — and then never retried the screenshot. Instead the PR description got a 184-line markdown description of what the page would look like, in lieu of a PNG. The deps were installed. The retry never fired.

Path validation was the weakest of the four — startsWith on the user-supplied path, no normalization, no .. block. The class of weakness is that a path that looks like it’s under public/images/ can still resolve elsewhere when the OS interprets it. I’m not going to spell out the exact bypass; the point is that a one-line startsWith check is not a path validator, and Run 4 shipped one.

Run 4’s “screenshot” is a 184-line markdown file. The opening:

Page Description: /admin/images

Overall Layout

The /admin/images page displays a dashboard-style view of all image directories with a neon brutalist design consistent with the existing admin theme.

Header Section

At the top:

  • Title: // Images in monospace font with primary color (cyan/teal)
  • Stats bar showing:

    • Total directories count
    • Total files count
    • Total size in human-readable format (MB/GB)
    • Orphaned count (in warning yellow/orange color, only shown if > 0)

…and 165 more lines of design notes.

Blind scoring

Rubric, weights, and scores:

Dimension Weight Run 1 Run 2 Run 3 Run 4
Correctness 25% 5.0 5.0 5.0 4.0
Design 15% 4.5 5.0 4.0 3.0
Code quality 20% 5.0 5.0 4.5 2.5
Engineering judgment 15% 4.5 4.0 5.0 2.5
Scope discipline 10% 4.5 4.5 4.0 3.5
Commit hygiene 10% 4.5 4.0 4.5 3.5
Surprise 5% 4.0 3.5 5.0 2.5
Weighted total 4.68 4.48 4.36 3.18

Scoring notes I wrote during the blind pass, before the reveal:

  • Run 1 — “Most defensive of the four. The path validator is the kind of code I’d want to ship to production. Loses half a design point for being slightly less visually polished than Run 2.”
  • Run 2 — “Tightest TypeScript I’ve seen this week. Visual polish is the best of the four. Path validation is fine but not paranoid. Stopped at v1 — didn’t iterate, didn’t second-guess. Probably Sonnet.”
  • Run 3 — “Mid-task architecture pivot, three iterations on orphan detection, the only run that produced an honest orphan count. Took the longest. Most thoughtful. Probably Opus 4.6.”
  • Run 4 — “Two overlapping libraries, dead code left behind, weak path validation, fell back to a markdown description instead of a real screenshot. The dependency install was right there. The retry never came. Probably Qwen.”

Two guesses right (Run 1 = Opus 4.7, Run 4 = Qwen). Two guesses swapped. Run 2 was Sonnet 4.6. Run 3 was Opus 4.6. I had them reversed — but I had the behavior right. I thought “polished, decisive, stopped at v1” was Sonnet, and it was. I thought “iterated three times until the answer was honest” was Opus, and it was. The guesses were wrong about which Opus, not about the disposition.

The reveal

Rank Model Score Headline
1 Opus 4.7 4.68 Strongest path validator, multi-status DELETE API, three screenshots
2 Sonnet 4.6 4.48 Tightest TypeScript, best visual polish, fastest to “done”
3 Opus 4.6 4.36 Only model that noticed the slug-prefix problem and iterated until orphan detection was honest
4 Qwen 3.5 35B-A3B 3.18 Missing screenshot, weakest path validation, architectural churn

What surprised me

Sonnet beat Opus 4.6. I didn’t expect that. On previous bakeoffs Opus has been the model that goes deeper. Here, Sonnet’s tighter implementation and faster decisive shipping outscored Opus’s iteration. Two different success modes:

  • Sonnet’s mode: get to a clean v1 fast, polish what’s there, stop. Trust the spec.
  • Opus 4.6’s mode: ship a first pass, look at the output, notice when it disagrees with reality, iterate.

Neither is wrong. If the spec is precise and “ship the feature” is the success criterion, Sonnet’s mode wins. If the spec is approximate and “produce a correct answer” is the success criterion, Opus’s mode wins. On this task, Sonnet was polished enough that Opus’s iteration premium didn’t make up the gap.

Opus 4.6’s slug-prefix insight is the engineering moment of the bakeoff. Three models took the spec literally and produced false-positive orphans. One model checked its work, noticed the discrepancy, and kept going until the answer was honest. The cost was time — Opus 4.6 took 28.1 minutes, 3x longer than Opus 4.7’s 9.2 minutes, and 146 messages versus Opus 4.7’s 84. The benefit was the only correct orphan count in the bunch. That’s the trade-off, and on a real codebase I’d take it every time — but it’s worth being honest that the iteration premium showed up in the bill as well as the clock.

Qwen failed roughly where predicted. Pre-launch I’d written down four likely failure modes: skip orphan detection, weak design system match, miss the screenshot, forget to push. Three of those landed at least partially — Qwen did implement orphan detection, but did it naively, which is how the predicted weakness actually manifested; the design fit was rough; the screenshot was missed; the push went fine. The pattern wasn’t where I expected, though. Qwen didn’t fail at the planning level. It failed at the retry level. Every concrete step was reasonable. What was missing was the loop — retry the screenshot after installing the deps, clean up the dead code after the refactor, question whether two utility libraries were one too many. That’s the agentic gap, and it’s narrower than a year ago but still visible.

The screenshot step was the cleanest differentiator. Same task, same workspace template, same Playwright MCP, same headless Chromium dependency stack. Three models installed the missing libraries and got real PNGs. One model installed the libraries and produced a markdown description instead. Same workspace, same tools, completely different outcomes. If you wanted to test agentic loop-closing in a single observable step, this would be it.

Two of four replaced the Settings placeholder; two kept it. The spec allowed either. Both Opus runs replaced it; Sonnet and Qwen kept it alongside the new Images card. Not a quality signal — a reading of the spec — but interesting that the two Opus variants made the same call independently, and the two non-Opus models made the same opposite call.

What the bill says

The rubric scores were one half of the bakeoff. The other half lives in Coder’s chat-cost API. Coder’s OSS deployment exposes /api/experimental/chats/cost/{user}/summary — an experimental endpoint that returns per-chat input tokens, output tokens, cache reads, cache writes, message counts, and runtime. (Coder Premium has a fuller “AI Bridge” cost product; on OSS, the experimental chats endpoint is the equivalent and gives you everything you need to do this analysis.)

Querying per-chat instead of per-model matters. My first pass aggregated by model and the Opus 4.7 totals looked enormous — until I realized the rollup had silently combined two chats running on the same model: this judging thread plus the actual Opus 4.7 contestant run. After identifying the contestant by its chat ID prefix (2c4e8f98) and isolating to that session, the numbers got honest. The lesson: for clean bakeoff stats, query at the chat-id level, not by model. Two sessions on the same model will silently pool.

The finding the dashboard didn’t surface: Opus 4.7 won the rubric (4.68), but weighted by cost-per-rubric-point at Anthropic list prices, Sonnet 4.6 wins decisively. $0.37 per rubric point for Sonnet vs $3.87 for Opus 4.7 and $3.63 for Opus 4.6. Sonnet was the only economically sensible choice for a task this size.

The Qwen line is the other one to sit with. Qwen finished in 6.4 minutes — faster than every Claude run — and produced the lowest-scoring artifact. Locally hosted inference is genuinely faster per turn (~4 seconds vs 6–13 seconds for the Claude runs); the shortfall was per-turn productivity, not latency. A longer Qwen run might have closed the gap. A 6-minute Qwen run did not.

One honest caveat on the cost numbers: this OSS Coder deployment doesn’t have model cost config set, so the dashboard reported $0 across the board. The costs in the table below are list-price estimates calculated from the raw token counts. Production Anthropic billing would match closely modulo any rate plan.

Model Input Output Cache R Cache W Runtime Messages Est Cost
Opus 4.7 99 32,114 4,772,142 454,581 9.2 min 84 $18.09
Opus 4.6 14,671 45,137 6,493,552 132,707 28.1 min 146 $15.83
Sonnet 4.6 110 25,935 3,097,881 85,057 15.2 min 106 $1.64
Qwen 3.5 35B-A3B 55,615 23,743 4,253,874 0 6.4 min 88 $0.00

Cost-efficiency, $/rubric point (lower is better): Opus 4.7 $3.87, Opus 4.6 $3.63, Sonnet 4.6 $0.37, Qwen $0.00. Pricing: Opus $15/M in, $75/M out, $1.50/M cache read, $18.75/M cache write; Sonnet $3/M in, $15/M out, $0.30/M cache read, $3.75/M cache write; Qwen runs locally on the RTX 5090.

By the Numbers

  • 4 models tested in isolated Coder Agents sessions — Opus 4.7, Opus 4.6, Sonnet 4.6, Qwen 3.5 35B-A3B
  • 4 branches pushed (feature/image-management-run-1 through run-4); 0 PRs opened to preserve isolation
  • 4/4 builds passed npm run build on Node 20.20.2 against the engine baseline
  • 3/4 screenshots succeeded — Qwen installed the headless-browser deps but never retried the capture; fell back to a markdown description of the page
  • 1/4 models produced an honest orphan count (Opus 4.6, 1 real orphan); the other three reported 8 false-positive orphans from naive slug matching
  • 2/4 blind identity guesses correct (Opus 4.7, Qwen); the two Claude behavioral reads were right but attributed to the wrong Opus
  • 3 pre-launch fairness fixes shipped before the bakeoff could run — Node 20 in the workspace image, a corrected system-instructions block, and the prompt-poisoning catch that anonymized the branches
  • 2 repos touched to ship the fairness work — coder-templates (Dockerfile + system instructions) and the bakeoff prompt iteration in the planning thread
  • ~640 lines of code added per implementation on average (range 595–687); roughly 6–8 new files per branch
  • 2 new routes per implementation — an admin page and an API route with a destructive verb
  • 84 / 146 / 106 / 88 messages sent in the four chat sessions (Opus 4.7 / Opus 4.6 / Sonnet 4.6 / Qwen); 9.2 / 28.1 / 15.2 / 6.4 minutes of wall-clock runtime
  • ~$35.56 total bakeoff cost at Anthropic list prices — about a fancy dinner for four independent attempts at a real feature with judgable artifacts
  • $0.37 vs $3.87 per rubric point — Sonnet 4.6’s cost-efficiency vs Opus 4.7’s. Ten times cheaper for slightly higher quality.
  • 1 result I didn’t expect: Sonnet beat Opus 4.6 on rubric (4.48 vs 4.36) and beat both Opus models by 10x on cost-efficiency
  • 1 follow-up filed in content/TODO.md: build scripts/bakeoff-stats.sh so the next round’s per-chat aggregation is one command instead of a manual jq exercise

SMTP Relay Server Setup 2026: Secure High-Volume Email Sending Guide

SMTP Relay Server Setup 2026: Secure High-Volume Email Sending Guide

An SMTP relay server is the backbone of every high-volume email operation. Whether you’re sending transactional order confirmations, marketing campaigns, or system notifications, the relay server determines delivery speed, security, and inbox placement.

This guide covers production SMTP relay architecture for 2026: self-hosted KumoMTA vs cloud SMTP relay services, secure configuration (TLS, AUTH, IP allowlisting), performance tuning, and real-world monitoring.

What Is an SMTP Relay Server?

An SMTP relay accepts outbound email from your applications and delivers it to the final destination mail servers (Gmail, Outlook, Yahoo, etc.). Unlike a local MTA that handles both inbound and outbound, a relay focuses purely on outbound delivery optimization.

Typical architecture:

Application → SMTP Relay → Internet → Recipient Mail Server
              (KumoMTA)   (TLS)

The relay handles:

  • Message queuing and retry logic
  • TLS encryption with certificate management
  • Rate limiting and traffic shaping
  • DKIM signing and SPF passthrough
  • Bounce processing and delivery tracking

Self-Hosted vs Cloud SMTP Relay

Factor Self-Hosted (KumoMTA) Cloud SMTP (SendGrid, Mailgun)
Control Full (server, config, logs) Limited (API only)
Cost at 10M/month ~$1,500 (infra) ~$1,200 (paid plan)
Cost at 100M/month ~$5,000 (infra) ~$15,000+
Throughput ceiling Unlimited (scale infra) Shared, tier-limited
Compliance Full GDPR/CAN-SPAM control Shared responsibility
Setup complexity Medium Low
AI optimization Yes (KumoMTA native) Limited
Custom bounce processing Full Lua control API/webhook only

Choose self-hosted if: You send > 5M emails/month, have engineering capacity, need full compliance control, or want to eliminate per-email pricing.

Choose cloud SMTP if: You’re under 1M emails/month, have no infra engineering, or need rapid setup without infrastructure management.

KumoMTA as SMTP Relay: Production Configuration

Core Relay Configuration

-- /etc/kumomta/relay.conf
-- Production SMTP relay configuration

-- SMTP Listener (accepts from internal apps)
kumo.start_smtp_listener {
    listen = "[::]:2525",  -- Internal relay port
    name = "relay-in",
    relay_hosts = { "10.0.0.0/8", "172.16.0.0/12" }, -- Internal networks only
    auth_require_tls = true, -- Require AUTH from application servers
}

-- HTTP API for application injection
kumo.start_http_listener {
    listen = "[::]:8080",
    trusted_hosts = { "10.0.0.0/8", "127.0.0.1" },
}

-- DKIM signing for all outbound
kumo.configure_dkim_signing {
    domain = "example.com",
    selector = "mail",
    key_file = "/etc/kumomta/keys/mail._domainkey.example.com.pem",
    headers = { "From", "To", "Subject", "Date", "Message-ID" },
}

-- TLS for outbound delivery
kumo.configure_tls {
    min_tls_version = "1.3",
    ciphers = "ECDHE-RSA-AES256-GCM-SHA384",
}

-- Prometheus metrics
kumo.start_http_listener {
    listen = "[::]:2000",
    trusted_hosts = { "127.0.0.1" },
}

Application Integration via HTTP API

Applications inject mail via KumoMTA’s HTTP API:


bash
curl -X POST http://kumomta-relay:8080/v1/message 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer YOUR_API_KEY" 
  -d '{
    "from": "[email protected]",

Building a Dead Letter Queue for Shopify Webhooks (Production-Ready Guide)

Building a Dead Letter Queue for Shopify Webhooks

Your Shopify webhook just failed. Network timeout. Database deadlock. Third-party API down.

The webhook payload? Gone forever.

Unless you have a dead letter queue.

This guide walks through building a production-ready DLQ system for Shopify webhooks. I’ll show you the architecture, code examples, and mistakes to avoid based on real production systems.

Why Shopify’s Built-in Retry Isn’t Enough

Shopify retries failed webhooks up to 19 times over 48 hours. After that, they stop delivering to your endpoint.

Attempt 1: Immediate
Attempt 2: 1 second later
Attempt 3: 2 seconds later
...
Attempt 19: 48 hours later
Then: 💀 Webhook deleted forever

For production systems handling payments, orders, and inventory, this isn’t acceptable. You need a safety net.

Architecture Overview

Here’s the high-level flow:

Shopify → Your Webhook Endpoint → Primary Queue → Worker → Success ✓
                    ↓                                    ↓
              200 OK (always)                        Failure ✗
                                                          ↓
                                                   Dead Letter Queue
                                                          ↓
                                                   Retry Logic

Critical principle: Always return 200 OK to Shopify immediately. Process webhooks asynchronously.

The Stack

I’ll use this stack for examples (adapt to your needs):

  • Queue: Redis with Bull (Node.js) or AWS SQS
  • Database: PostgreSQL for DLQ storage
  • Runtime: Node.js with Express
  • Monitoring: Custom dashboard + alerts

Implementation: Step by Step

1. Webhook Reception (Never Block Here)

// webhook-receiver.js
const express = require('express');
const crypto = require('crypto');
const { primaryQueue } = require('./queues');

const app = express();

app.post('/webhooks/shopify/:topic', express.raw({ type: 'application/json' }), async (req, res) => {
  const topic = req.params.topic;
  const hmac = req.get('X-Shopify-Hmac-Sha256');
  const body = req.body;

  // Verify webhook signature
  if (!verifyWebhook(body, hmac)) {
    return res.status(401).send('Invalid signature');
  }

  // Respond to Shopify IMMEDIATELY
  res.status(200).send('OK');

  // Queue for async processing (never blocks)
  try {
    await primaryQueue.add(topic, {
      topic,
      shopDomain: req.get('X-Shopify-Shop-Domain'),
      webhookId: req.get('X-Shopify-Webhook-Id'),
      payload: JSON.parse(body.toString()),
      receivedAt: new Date().toISOString()
    }, {
      attempts: 6,
      backoff: {
        type: 'exponential',
        delay: 10000 // Start with 10 seconds
      }
    });
  } catch (error) {
    // If queuing fails, write directly to DLQ
    await saveToDLQ({
      topic,
      payload: JSON.parse(body.toString()),
      error: 'Failed to queue webhook',
      failureReason: error.message
    });
  }
});

function verifyWebhook(body, hmac) {
  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
    .update(body)
    .digest('base64');
  return hash === hmac;
}

2. Worker with DLQ Fallback

// webhook-worker.js
const { primaryQueue, dlqQueue } = require('./queues');
const { processWebhook } = require('./processors');
const { saveToDLQ, incrementRetryCount } = require('./dlq-storage');

primaryQueue.process(async (job) => {
  const { topic, payload, webhookId, shopDomain } = job.data;

  try {
    // Your business logic here
    await processWebhook(topic, payload, shopDomain);

    // If we got here, processing succeeded
    return { success: true, webhookId };

  } catch (error) {
    // Categorize the error
    if (isTransientError(error)) {
      // Let Bull retry with exponential backoff
      throw error;
    } else {
      // Permanent error - move to DLQ immediately
      await saveToDLQ({
        webhookId,
        topic,
        shopDomain,
        payload,
        error: error.message,
        stackTrace: error.stack,
        retryCount: job.attemptsMade,
        firstFailedAt: job.timestamp,
        lastAttemptedAt: new Date().toISOString()
      });

      // Don't throw - we handled it
      return { success: false, movedToDLQ: true };
    }
  }
});

// When all retries exhausted, Bull calls this
primaryQueue.on('failed', async (job, error) => {
  if (job.attemptsMade >= job.opts.attempts) {
    await saveToDLQ({
      webhookId: job.data.webhookId,
      topic: job.data.topic,
      shopDomain: job.data.shopDomain,
      payload: job.data.payload,
      error: 'Max retries exceeded',
      stackTrace: error.stack,
      retryCount: job.attemptsMade,
      firstFailedAt: job.timestamp,
      lastAttemptedAt: new Date().toISOString()
    });
  }
});

function isTransientError(error) {
  // Network issues, rate limits, timeouts
  return error.code === 'ECONNREFUSED' ||
         error.code === 'ETIMEDOUT' ||
         error.statusCode === 429 ||
         error.statusCode === 503 ||
         error.message.includes('deadlock');
}

3. DLQ Storage Schema

-- dlq_webhooks table
CREATE TABLE dlq_webhooks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  webhook_id VARCHAR(255) UNIQUE,
  topic VARCHAR(100) NOT NULL,
  shop_domain VARCHAR(255) NOT NULL,
  payload JSONB NOT NULL,
  error_message TEXT,
  stack_trace TEXT,
  retry_count INTEGER DEFAULT 0,
  first_failed_at TIMESTAMP NOT NULL,
  last_attempted_at TIMESTAMP NOT NULL,
  status VARCHAR(50) DEFAULT 'pending', -- pending, processing, resolved, abandoned
  resolved_at TIMESTAMP,
  notes TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Indexes for querying
CREATE INDEX idx_dlq_status ON dlq_webhooks(status);
CREATE INDEX idx_dlq_topic ON dlq_webhooks(topic);
CREATE INDEX idx_dlq_shop ON dlq_webhooks(shop_domain);
CREATE INDEX idx_dlq_first_failed ON dlq_webhooks(first_failed_at);

4. DLQ Storage Functions

// dlq-storage.js
const { Pool } = require('pg');
const pool = new Pool();

async function saveToDLQ(webhookData) {
  const {
    webhookId,
    topic,
    shopDomain,
    payload,
    error,
    stackTrace,
    retryCount = 0,
    firstFailedAt,
    lastAttemptedAt
  } = webhookData;

  const query = `
    INSERT INTO dlq_webhooks (
      webhook_id, topic, shop_domain, payload,
      error_message, stack_trace, retry_count,
      first_failed_at, last_attempted_at
    ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
    ON CONFLICT (webhook_id) 
    DO UPDATE SET
      retry_count = dlq_webhooks.retry_count + 1,
      last_attempted_at = $9,
      error_message = $5,
      stack_trace = $6
    RETURNING id
  `;

  try {
    const result = await pool.query(query, [
      webhookId,
      topic,
      shopDomain,
      JSON.stringify(payload),
      error,
      stackTrace,
      retryCount,
      firstFailedAt || new Date(),
      lastAttemptedAt || new Date()
    ]);

    // Alert if this is a new DLQ entry
    if (result.rowCount > 0) {
      await alertDLQEntry(webhookId, topic, shopDomain);
    }

    return result.rows[0];
  } catch (err) {
    console.error('Failed to save to DLQ:', err);
    // This is serious - DLQ itself failed
    // Log to external service (Sentry, etc.)
  }
}

async function getDLQWebhooks(status = 'pending', limit = 100) {
  const query = `
    SELECT * FROM dlq_webhooks
    WHERE status = $1
    ORDER BY first_failed_at ASC
    LIMIT $2
  `;

  const result = await pool.query(query, [status, limit]);
  return result.rows;
}

async function markAsResolved(webhookId, notes = null) {
  const query = `
    UPDATE dlq_webhooks
    SET status = 'resolved',
        resolved_at = NOW(),
        notes = $2
    WHERE webhook_id = $1
  `;

  await pool.query(query, [webhookId, notes]);
}

module.exports = {
  saveToDLQ,
  getDLQWebhooks,
  markAsResolved
};

5. Automated DLQ Retry Worker

// dlq-retry-worker.js
const { getDLQWebhooks, markAsResolved, saveToDLQ } = require('./dlq-storage');
const { processWebhook } = require('./processors');

async function processDLQWebhooks() {
  const webhooks = await getDLQWebhooks('pending', 50);

  for (const webhook of webhooks) {
    try {
      // Update status to prevent concurrent processing
      await updateDLQStatus(webhook.webhook_id, 'processing');

      // Try to process again
      await processWebhook(
        webhook.topic,
        webhook.payload,
        webhook.shop_domain
      );

      // Success! Mark as resolved
      await markAsResolved(webhook.webhook_id, 'Auto-resolved by retry worker');

    } catch (error) {
      // Still failing
      const newRetryCount = webhook.retry_count + 1;

      if (newRetryCount >= 10) {
        // Give up after 10 DLQ retries
        await updateDLQStatus(webhook.webhook_id, 'abandoned');
        await alertPermanentFailure(webhook);
      } else {
        // Save back to DLQ with updated count
        await saveToDLQ({
          ...webhook,
          retryCount: newRetryCount,
          error: error.message,
          stackTrace: error.stack,
          lastAttemptedAt: new Date().toISOString()
        });
      }
    }
  }
}

// Run every 5 minutes
setInterval(processDLQWebhooks, 5 * 60 * 1000);

Monitoring Dashboard

// dlq-dashboard.js - Express route
app.get('/api/dlq/stats', async (req, res) => {
  const stats = await pool.query(`
    SELECT
      status,
      COUNT(*) as count,
      MIN(first_failed_at) as oldest,
      MAX(first_failed_at) as newest
    FROM dlq_webhooks
    GROUP BY status
  `);

  const byTopic = await pool.query(`
    SELECT
      topic,
      COUNT(*) as count
    FROM dlq_webhooks
    WHERE status = 'pending'
    GROUP BY topic
    ORDER BY count DESC
  `);

  res.json({
    stats: stats.rows,
    byTopic: byTopic.rows
  });
});

Alerting Setup

// alerting.js
const { sendSlackAlert } = require('./slack');

async function alertDLQEntry(webhookId, topic, shopDomain) {
  // Check DLQ depth
  const depth = await getDLQDepth();

  if (depth > 100) {
    await sendSlackAlert({
      channel: '#alerts-critical',
      text: `🚨 DLQ depth exceeds 100: ${depth} webhooks`,
      priority: 'critical'
    });
  }
}

async function alertPermanentFailure(webhook) {
  await sendSlackAlert({
    channel: '#alerts-high',
    text: `⚠️ Webhook permanently failed after 10 retriesnTopic: ${webhook.topic}nShop: ${webhook.shop_domain}nWebhook ID: ${webhook.webhook_id}`,
    priority: 'high'
  });
}

Testing Your DLQ

// test-dlq.js
describe('Dead Letter Queue', () => {
  it('should save webhook to DLQ on processing failure', async () => {
    const testWebhook = {
      webhookId: 'test-123',
      topic: 'orders/create',
      shopDomain: 'test-shop.myshopify.com',
      payload: { id: 123, total: 50.00 },
      error: 'Database connection failed',
      stackTrace: 'Error: ...',
      retryCount: 3
    };

    await saveToDLQ(testWebhook);

    const dlqWebhooks = await getDLQWebhooks('pending');
    expect(dlqWebhooks).toHaveLength(1);
    expect(dlqWebhooks[0].webhook_id).toBe('test-123');
  });

  it('should retry webhook from DLQ', async () => {
    // Test retry logic
  });

  it('should mark webhook as abandoned after max retries', async () => {
    // Test abandonment logic
  });
});

Production Considerations

Idempotency

Store processed webhook IDs to prevent duplicate processing:

async function processWebhook(topic, payload, shopDomain) {
  const webhookId = payload.id || payload.admin_graphql_api_id;

  // Check if already processed
  const alreadyProcessed = await redis.get(`processed:${webhookId}`);
  if (alreadyProcessed) {
    console.log(`Webhook ${webhookId} already processed, skipping`);
    return;
  }

  // Process webhook
  await yourBusinessLogic(payload);

  // Mark as processed (expire after 7 days)
  await redis.setex(`processed:${webhookId}`, 7 * 24 * 60 * 60, '1');
}

Exponential Backoff Configuration

const RETRY_CONFIG = {
  attempts: 6,
  backoff: {
    type: 'exponential',
    delay: 10000, // 10 seconds
    options: {
      maxDelay: 1800000, // 30 minutes max
    }
  }
};

// Results in retry schedule:
// 1. 10 seconds
// 2. 1 minute
// 3. 5 minutes
// 4. 15 minutes
// 5. 30 minutes
// 6. 30 minutes

Memory Management

For high-volume stores:

// Process DLQ in batches
async function processDLQBatch(batchSize = 50) {
  const webhooks = await getDLQWebhooks('pending', batchSize);

  // Process in parallel with concurrency limit
  await Promise.all(
    webhooks.map(webhook => 
      limiter.schedule(() => retryWebhook(webhook))
    )
  );
}

// Use p-limit for concurrency control
const pLimit = require('p-limit');
const limiter = pLimit(10); // Max 10 concurrent

Common Mistakes to Avoid

❌ Processing webhooks synchronously in the HTTP handler

// DON'T DO THIS
app.post('/webhook', async (req, res) => {
  await processWebhook(req.body); // Blocks!
  res.status(200).send('OK'); // Too late, Shopify timed out
});

❌ No maximum retry limit

// DON'T DO THIS
while (!success) {
  await retry(); // Infinite loop
}

❌ Not categorizing errors

// DON'T DO THIS
catch (error) {
  throw error; // Always retry, even for permanent failures
}

Monitoring Checklist

Monitor these metrics:

  • ✅ DLQ depth (alert > 100)
  • ✅ Age of oldest message (alert > 4 hours)
  • ✅ Retry success rate
  • ✅ Error patterns (group by error type)
  • ✅ Processing latency (p50, p95, p99)
  • ✅ Webhook topics in DLQ (which events fail most)

Resources

Full Implementation

The complete working implementation with all the code above is available on our blog:
👉 Dead Letter Queue Shopify Webhooks: Complete Guide

What’s your approach to webhook reliability? Drop your setup in the comments below.

Building Shopify integrations? We help e-commerce brands build fault-tolerant systems. Check out Kolachi Tech for more technical deep-dives.

I disabled push notifications on my own AI app in 24 hours — here is what I rebuilt

I disabled push notifications on my own AI productivity app within 24 hours of shipping it.

That was the moment I realized I had built something that looked useful but was actually attention spam dressed up in a clean UI.

Here’s what was wrong, what I learned, and the architecture I rebuilt around it.

The “helpful” trap

The first version of my product (then called EVE, now Jigeum) did the obvious thing: connect Gmail, classify emails, surface anything important via push notification.

The logic seemed sound. The execution was a disaster.

Day 1, 9am: push notification — “Stripe receipt may need attention”
Day 1, 9:14am: push — “LinkedIn message from a recruiter”
Day 1, 9:32am: push — “GitHub PR review request”
Day 1, 10:01am: push — “Newsletter — possibly important”

By noon I had 14 notifications. By 5pm I had silenced the app on my phone.

I had recreated the exact problem I was trying to solve: another channel demanding my attention, no smarter than the inbox it was sitting on top of.

The wrong mental model

Here’s the assumption almost every AI productivity tool makes — and the one I had to unlearn:

“If something is important, notify the user. If it’s not, don’t.”

This is wrong. Importance is binary. Attention is not.

The real model is: every signal has an escalation level, and most signals deserve none.

A contract waiting for signature is not the same as a newsletter from a YC partner you respect. Both are “important.” Only one should interrupt your morning.

The architecture I rebuilt: 5-tier escalation

Every incoming signal — email, calendar event, extracted commitment — gets classified into exactly one tier:

SILENT    → never surfaced
QUEUE     → added to a review list, no notification
PUSH      → mobile push, the actual interrupt
CALL      → urgent override (not yet built)
AUTO      → handled without asking me

The default is QUEUE. Not PUSH. Most things just sit there until I open the app.

This single change — defaulting to the quietest reasonable tier instead of the noisiest — is the difference between a tool I use and a tool I muted.

Trust Score: who actually deserves to reach you

Routing depends on the sender. Each contact has a Trust Score (0–100) derived from real interaction history:

interface TrustScore {
  userId: string;
  contactEmail: string;
  score: number;               // 0–100
  interactionCount: number;
  avgResponseMinutes: number | null;
  lastInteractionAt: Date | null;
}

A cold sender I’ve never replied to: ~10.
A teammate I exchange messages with daily: ~95.

Tier assignment combines Trust Score × content urgency × time-of-day context. A 95 score sending a question gets PUSH. A 10 score sending the same question gets QUEUE. Same email content, different outcome — because who matters as much as what.

Commitment Ledger: the feature I didn’t know I needed

This was the unexpected one.

Every email where I had written “I’ll send the contract by Friday” or “Let me get back to you next week” — those were commitments I kept forgetting. They lived inside threads. The other person remembered. I didn’t.

interface Commitment {
  id: string;
  userId: string;
  title: string;
  kind: "DELIVERABLE" | "MEETING" | "FOLLOW_UP" | "DECISION";
  owner: "USER" | "COUNTERPART";  // who owes whom
  dueAt: Date | null;
  dueText: string | null;          // "by Friday", "next week"
  confidence: number;              // 0–1
  status: "OPEN" | "DONE" | "OVERDUE";
}

The confidence score matters. “Let’s sync sometime” → 0.3, ignored. “Please send the NDA by Tuesday EOD” → 0.9, surfaced immediately.

In four weeks of dogfooding, this caught three commitments I would have genuinely dropped. That’s the metric I judge the whole product by now.

What changed when I rebuilt around this

Before After
Default tier: PUSH Default tier: QUEUE
Routing: keyword/urgency heuristics Routing: Trust Score × content × context
Surface: notification feed Surface: single morning page (Command Center)
My behavior: disabled the app My behavior: open it before checking email

The Command Center is one page with four blocks: Morning Briefing, Approval Queue, Commitment Ledger, Reply Needed. I open it once before email and I’m done.

I haven’t opened raw Gmail first thing in the morning in 3 weeks.

The principle

If I had to compress the lesson into one rule it would be this:

Default to silence. Earn the right to interrupt.

Most “smart” tools fail because they assume the user wants to be helped at every opportunity. The user does not. The user wants their attention managed down, not flooded with more “important” inputs.

Stack

For the curious:

  • API: Fastify + TypeScript + Prisma + PostgreSQL (Supabase)
  • Web: Next.js 15 App Router
  • AI: Claude Sonnet for content analysis, Claude Haiku for classification
  • Email: Gmail API with incremental sync
  • Push: Web Push API + service workers
  • Deploy: Render (API) + Vercel (web)

Try it

Jigeum is in private beta. Connect Gmail + Calendar, initial sync takes about 30 seconds, and you’ll see your inbox classified by tier within a minute.

If you’re a founder, solo operator, or anyone whose inbox is currently managing them — I’d genuinely value the feedback. Especially where the classification gets it wrong. That’s where the next iteration comes from.

Architecture questions welcome in the comments.

Built solo. The first version annoyed me. The second one I actually use.