iPhone 18 serisinde Dinamik Ada küçülebilir
Apple’ın iPhone 18 serisinde Dinamik Ada’yı %35 oranında daraltabileceği konuşuluyor. Sızıntılar, bu değişikliğin sadece Pro modellerle sınırlı kalmayıp tüm aileye yayılabileceğini gösteriyor.
Apple’ın iPhone 18 serisinde Dinamik Ada’yı %35 oranında daraltabileceği konuşuluyor. Sızıntılar, bu değişikliğin sadece Pro modellerle sınırlı kalmayıp tüm aileye yayılabileceğini gösteriyor.Spring MVC and Spring WebFlux solve the same problem — handling HTTP requests — but through completely different concurrency models. With Spring Boot 4 and Java 25 both shipping in late 2025, the landscape has shifted significantly. Let me walk through every dimension that matters for making the right architectural decision.
Table of Contents
1. Programming model: imperative vs reactive
The most visible difference between Spring MVC and Spring WebFlux isn’t in the framework itself — it’s in how you write code:
Mono<T> (zero or one result) or Flux<T> (zero to N results).Spring MVC:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// Blocks. Thread waits for DB.
return service.findById(id);
}
}
Spring WebFlux:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable Long id) {
// Non-blocking pipeline.
return service.findById(id);
}
}
Composing multiple async calls
Spring MVC — straightforward procedural code:
public OrderSummary getOrderSummary(Long orderId) {
Order order = orderRepo.findById(orderId);
User user = userService.findById(order.userId);
List<Item> items = itemService.findByOrder(orderId);
return new OrderSummary(order, user, items);
}
Spring WebFlux — reactive operator chains:
public Mono<OrderSummary> getOrderSummary(Long orderId) {
return orderRepo.findById(orderId)
.flatMap(order ->
Mono.zip(
userService.findById(order.userId),
itemService.findByOrder(orderId),
(user, items) -> new OrderSummary(order, user, items)));
}
⚠️ The reactive tax: The MVC version is plain procedural code that any Java developer can read immediately. The WebFlux version requires knowing
flatMap,Mono.zip, and tuple destructuring — all just to express the same business logic. This is the learning curve that drives a lot of teams back to MVC, and it’s worth taking seriously before you commit.
Error handling
MVC — familiar try/catch:
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.notFound().build();
}
WebFlux — operator-based errors:
return userService.findById(id)
.map(ResponseEntity::ok)
.onErrorResume(
UserNotFoundException.class,
e -> Mono.just(ResponseEntity.notFound().build())
);
💡 WebFlux also supports a fully functional router model with
RouterFunctionandHandlerFunctionas an alternative to annotations — another concept on the pile.
2. Framework internals: what’s actually different under the hood
Beneath the annotation layer, the two stacks are built on completely different foundations.
| Layer | Spring MVC | Spring WebFlux |
|---|---|---|
| Entry point | DispatcherServlet |
DispatcherHandler |
| HTTP abstraction | HttpServletRequest / Response |
ServerWebExchange |
| Filter API | OncePerRequestFilter |
WebFilter (returns Mono<Void>) |
| Security | SecurityFilterChain |
SecurityWebFilterChain |
| Request context |
RequestContextHolder (ThreadLocal) |
ServerWebExchange (Reactor Context) |
| Foundation | Servlet API | Reactive Streams (Project Reactor) |
| Default server | Tomcat / Jetty | Netty |
📌 Spring Boot 4 note: Undertow has been dropped from Spring Boot 4 because it’s not yet compatible with Servlet 6.1. So the old trio of Tomcat / Jetty / Undertow is now Tomcat / Jetty on the MVC side.
DispatcherServlet vs DispatcherHandler
Spring MVC routes everything through DispatcherServlet, which implements the Servlet API and blocks the calling thread while the response is assembled.
WebFlux replaces this with DispatcherHandler, which implements WebHandler — every request is a reactive pipeline from first byte to last.
Jackson serialization
Both stacks default to Jackson 3 in Spring Boot 4, but the codec layer differs. MVC uses HttpMessageConverter implementations; WebFlux uses reactive HttpMessageReader / HttpMessageWriter.
⚠️ Custom
HttpMessageConverterbeans registered for MVC will not automatically carry over to WebFlux. You need to register aCodecConfigurerinstead.
Filters
MVC — Servlet filter:
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
log.info("→ {}", req.getRequestURI());
chain.doFilter(req, res);
log.info("← {}", res.getStatus());
}
}
WebFlux — WebFilter:
@Component
public class LoggingFilter implements WebFilter {
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
WebFilterChain chain) {
log.info("→ {}", exchange.getRequest().getPath());
return chain.filter(exchange)
.doFinally(s -> log.info("← done"));
}
}
Security
Spring Security 7 (Spring Boot 4) ships separate auto-configurations for each stack:
SecurityFilterChain with authorizeHttpRequests
SecurityWebFilterChain with authorizeExchange
UserDetailsService must become ReactiveUserDetailsService in WebFlux🚫 You cannot mix both stacks in one application. Spring Boot will auto-configure WebFlux if
spring-boot-starter-webfluxis on the classpath, even alongsidespring-boot-starter-web. Pick one and explicitly exclude the other.
3. Cross-cutting concerns compared
| Concern | Spring MVC | Spring WebFlux |
|---|---|---|
| Exception handling |
@ExceptionHandler, @ControllerAdvice
|
@ExceptionHandler, WebExceptionHandler
|
| Validation |
@Valid / BindingResult
|
@Valid / Mono<T>.handle()
|
| CORS |
@CrossOrigin / CorsConfiguration
|
@CrossOrigin / CorsWebFilter
|
| Streaming / SSE |
SseEmitter (thread occupied) |
Flux<ServerSentEvent> (native, zero threads) |
| WebSocket |
WebSocketHandler + Tomcat |
WebSocketHandler + Netty (lower overhead) |
| Observability | Micrometer — automatic with Tomcat span | Micrometer — requires Mono.contextWrite propagation |
| Jackson config | WebMvcConfigurer.configureMessageConverters |
WebFluxConfigurer.configureHttpMessageCodecs |
4. Context propagation & ThreadLocal
This is one of the most painful operational differences between the two stacks. In Spring MVC, every request lives on a dedicated thread from start to finish. Anything you put in a ThreadLocal — user identity, trace ID, request metadata — is available anywhere in the call stack via RequestContextHolder.
In WebFlux, a single request can hop across many threads managed by Netty’s event loop. ThreadLocal values simply disappear between operators.
MVC — RequestContextHolder just works
// Works anywhere in the MVC call stack
RequestAttributes attrs =
RequestContextHolder.getRequestAttributes();
String traceId = (String) attrs.getAttribute(
"X-Trace-Id", SCOPE_REQUEST);
WebFlux — Reactor Context must be threaded explicitly
// Store in Reactor Context at filter level
chain.filter(exchange)
.contextWrite(Context.of(
"traceId",
exchange.getRequest().getHeaders()
.getFirst("X-Trace-Id")));
// Read from Reactor Context downstream
Mono.deferContextual(ctx -> {
String traceId = ctx.get("traceId");
// ...
});
Spring Security context propagation
MVC automatically propagates the SecurityContext via SecurityContextHolder (ThreadLocal-backed).
WebFlux uses ReactiveSecurityContextHolder, which stores the context in the Reactor subscriber context.
⚠️ Calling
SecurityContextHolder.getContext()from inside a WebFlux handler returns empty. This is one of the most common bugs when people migrate or mix the stacks.
ScopedValue
Java 25 finalizes ScopedValue (previewed from JDK 20 through JDK 24, finalized in JDK 25), which replaces ThreadLocal in virtual-thread code. Unlike ThreadLocal, a ScopedValue is immutable within its scope, has no memory leak risk, and composes naturally across virtual threads.
Legacy ThreadLocal approach:
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
// Risk: must be cleaned up manually
// Risk: memory leaks with thread pools
TRACE_ID.set("abc-123");
try {
processRequest();
} finally {
TRACE_ID.remove();
}
Java 25 — ScopedValue:
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
// Automatically scoped — no cleanup needed
// Immutable — no accidental mutation
// Works correctly across virtual threads
ScopedValue.where(TRACE_ID, "abc-123")
.run(this::processRequest);
5. Transactions & persistence
Transaction management is arguably where WebFlux creates the most friction, because the relational database ecosystem has been blocking-first for decades.
MVC — @Transactional just works
In MVC, @Transactional binds a JDBC connection to the current thread via PlatformTransactionManager and TransactionSynchronizationManager (both ThreadLocal-based). Every JPA, JDBC, or Spring Data JPA call on the same thread participates in the transaction automatically.
@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
inventoryService.reserve(req);
paymentService.charge(req);
return order; // auto-commit or rollback
}
}
WebFlux — R2DBC required, reactive operators mandatory
@Service
public class OrderService {
@Transactional // uses R2dbcTransactionManager
public Mono<Order> placeOrder(OrderRequest req) {
return orderRepo.save(new Order(req))
.flatMap(order ->
inventoryService.reserve(req)
.then(paymentService.charge(req))
.thenReturn(order));
}
}
🚫 JPA does not work with WebFlux.
EntityManageris fundamentally synchronous and ThreadLocal-based. Using JPA in a WebFlux application causes connection leaks and correctness bugs. You must use R2DBC with Spring Data R2DBC repositories. Most legacy codebases that mix JPA + WebFlux are accidentally blocking the event loop — and end up with worse throughput than plain MVC.
R2DBC warning
R2DBC lacks feature parity with JPA: no first-level cache, no lazy loading, no JPQL, no criteria API. Complex queries require raw SQL via DatabaseClient. There have been notable production bugs too — for instance, r2dbc-pool 1.0.2 had a well-known issue where read-only transactions on pooled connections failed silently, fixed in 1.0.3+.
The killer argument for MVC + virtual threads
With virtual threads (Java 21+ / Spring Boot 3.2+), blocking JDBC calls no longer pin a platform thread. The JVM unmounts the virtual thread during I/O, freeing the carrier thread for other work. You get near-reactive throughput while keeping JPA, @Transactional, and the entire Spring Data JPA ecosystem intact.
6. Java 25 + Spring Boot 4: do we still need WebFlux?
What virtual threads actually change
The original rationale for WebFlux was simple: platform threads cost around 1 MB each, so you can realistically run only a few hundred before the JVM starts struggling. Reactive programming multiplexed thousands of concurrent requests over a small event-loop thread pool.
With virtual threads, that constraint disappears. The JVM can manage millions of virtual threads, each costing only a few KB, and automatically parks them during I/O without blocking the underlying carrier thread.
Enabling virtual threads in Spring Boot — one config line:
# application.yaml
spring:
threads:
virtual:
enabled: true
# That's it. Tomcat now runs every request on a virtual thread.
# Your existing imperative code is unchanged.
Structured Concurrency (still in preview in Java 25) — parallel calls, imperative style:
// Note: Structured Concurrency is still a preview feature as of Java 25
// Enable with --enable-preview
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> userService.get(id));
var prefs = scope.fork(() -> prefService.get(id));
scope.join();
return combine(user.get(), prefs.get());
}
This is the reactive Mono.zip() equivalent — plain Java, debuggable with standard tools, readable by any developer on the team. Worth noting that it’s still a preview API, so it’s not production-stable in the same way ScopedValue now is.
WebFlux still collapses with blocking JDBC. If your reactive pipeline calls a blocking driver, you block the Netty event-loop thread — far worse than blocking a virtual thread. This is the most common production mistake people make when adopting WebFlux without fully committing to the reactive stack.
Where WebFlux still belongs
Despite virtual threads making reactive unnecessary for most applications, there are genuine niches where WebFlux wins:
Flux<ServerSentEvent> with Netty is far more efficient than SseEmitter on Tomcat. Performance comparison
| Workload | MVC + Virtual Threads | WebFlux |
|---|---|---|
| Blocking I/O (JDBC) | ✅ Excellent | ❌ Collapses |
| Non-blocking I/O (R2DBC) | ✅ Good | ✅ Excellent |
| SSE / streaming | ⚠️ Adequate | ✅ Excellent |
| CPU-bound work | ✅ Good | ⚠️ Needs scheduler tuning |
| Developer productivity | ✅ Excellent | ⚠️ Steep learning curve |
7. Decision guide
Choose MVC + Virtual Threads when…
Choose WebFlux when…
The verdict
For most enterprise Java applications being built today, Spring MVC + Virtual Threads is the right default. You get near-reactive throughput with imperative code, full JPA compatibility, simpler security configuration, correct @Transactional behavior, easier debugging, and straightforward ScopedValue-based context propagation on Java 25.
WebFlux remains the best tool for streaming-first and fully-reactive architectures — but that is now a deliberate, specific architectural choice, not the default path to performance.
| Use case | Recommended |
|---|---|
| REST APIs | MVC + Virtual Threads |
| Streaming / SSE | WebFlux |
| JDBC / JPA | MVC + Virtual Threads |
| R2DBC only | Either |
| WebSocket at scale | WebFlux |
| Greenfield API | MVC + Virtual Threads |
Have you migrated from WebFlux to MVC + Virtual Threads in production? What was your experience? Drop a comment below.
When I wrote the first version of the magic cleaning tasks generator for Hounty I wasn’t particularly happy with it, but I thought that this is as good as it gets with Generative AI APIs and let it be till I have time to train my own model. Well, today I proved myself wrong and I want to show you exactly how I managed to drastically improve performance by adjusting the prompt in a few inconventional ways.
I encourage you to follow along and try the prompts yourself. I used Gemini and Claude, but pretty much any chat AI app or API should work. And if you have never worked with GenAI – that’s no problem – you don’t have to have any prior knowledge to follow along.
The Task
Here is a picture of my living room this morning:
It looks like this a few days a week and every time it’s a chore to get started. I do rely on Hounty to tell me what to do and where to start. So let’s try and replicate the core functionality with nothing but prompts and see how to improve them.
Take one: You are an expert
You’ve probably seen the advice to start your prompt with “you are an expert in …”. It sounds like a good idea, but let’s try and see what it actually does.
Paste an image of a messy room (yours or mine) into your AI chat and prompt this:
You are a home cleaning expert. Create a cleaning todo list of 3-5 most impactful tasks
Here’s what I got from Gemini:

And now let’s try without the “you’re an expert” preamble:

The second output is about half the size of the “expert” one and I find it much more actionable and clear: “Clear the Floor” vs “Clear the Floor Transit Zones”. So why does this happen?
Turns out, giving AI a title doesn’t unlock more knowledge, but instead it forces it to act like an expert or, in other words, researchers found that Expert Personas Improve LLM Alignment but Damage Accuracy.
Not to say that personas have no use, but in majority of cases adding “you are an expert” would result in a confident tone and bloated output. Which means you’ll burn more tokens for no benefit.
Skipping it works better.
Take two: Detailed prompt
On the first glance, a common advice to describe what you want in as many details as possible makes sense, so let’s give it a try.
Same image, more explicit prompt:
Look at this image. List every visible object. For each object, determine whether it is out of place based on what should be in a Living Room. Consider laundry, dishes and clutter specifically. Return your findings in a structured list of 3-5 most impactful cleaning items as JSON.
Example:
[{“task”:”Throw away newspapers”, “advice”:”Recycle as newspapers”}]
Here’s the json it returned, since it didn’t fit into the first screen:
[
{
"task": "Fold and put away laundry from the sofa",
"advice": "Sort by owner or garment type and move to the bedroom or closet to regain seating space."
},
{
"task": "Stow reusable shopping bags",
"advice": "Empty any remaining contents and store the Lidl bags in a pantry, kitchen drawer, or car trunk."
},
{
"task": "Clear the floor of miscellaneous items",
"advice": "Pick up the pink hairbrush and the black tub; return the brush to the bathroom and store the tub in a utility area."
},
{
"task": "Declutter the windowsill and cat tree base",
"advice": "Move non-decorative items and loose papers to a dedicated desk or filing system to improve natural light flow."
}
]
And now try a simpler version:
Create a todo list of 3-5 most impactful cleaning tasks. Return as JSON {title,advice}
Here’s the resulting json in full for comparison:
[
{
"title": "Clear the Sofa Surface",
"advice": "Fold or hang the large pile of clothing on the couch. Clearing this central furniture piece will immediately make the room feel functional and less overwhelmed by 'clutter weight'."
},
{
"title": "Consolidate Floor Items",
"advice": "Gather the loose items on the rug and the shopping bags by the door. Use the empty black bin shown in the foreground to quickly corral miscellaneous objects that don't have a dedicated home yet."
},
{
"title": "Clear the Windowsill and Radiator",
"advice": "Remove the papers and pink organizer from the windowsill. In a bright room, clear horizontal surfaces near windows reflect more light and create a much cleaner visual line."
},
{
"title": "Reset the Dining Area",
"advice": "Tuck the dining chairs neatly under the table and remove any small items from the tabletop. Aligning the furniture creates a sense of order that anchors the rest of the open space."
}
]
Both prompts produced a similar list, but the first one added a lot of noise, which would not only made it much harder to use in an API, but would also cost more since it used more input and output tokens.
And the pink brush the first list mentions? It’s a cat brush, it doesn’t belong in the bathroom – it belongs in a box with cat stuff. Skipping that detail made the second list appear more precise.
So why did the detailed prompt backfire?
Because the second prompt gives the model room to reason. We’ve stated the goal clearly and let it do its job. When we provide too many details in the context LLM experiences effects similar to overtraining a model. In other words:
Overtraining (often synonymous with overfitting) occurs when a machine learning model learns the training data too well, capturing noise and specific details rather than general patterns.
By giving the LLM too many details we limit the ways it can solve the problem and thus as a general rule – less is more. You can always add or clarify something later.
And once again, less tokens spent!
Take three: Structured data
Now that we have the tasks in place it would be nice to add the coordinates of the areas on the image which illustrates each task.
Let’s try and enchance the prompt to return just that:
Identify 3-5 most impactful cleaning tasks and return ONLY JSON{task,x,y,w,h} mapping tasks with the areas on the image where x,y are top-left corner pixels of the bounding box and w,h is width and height
Adding “ONLY JSON” was needed to make sure that LLM doesn’t interpret “do this and then that” as two separate requests and spits out text and JSON.
Hm, I didn’t ask for a “label”.
We could keep patching the JSON prompt, but there’s a better tool for the job – CSV(comma-separated values).
Try this adjusted prompt:
Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where x,y are top-left corner pixels of the bounding box and w,h is width and height
CSV is just as easy to parse as JSON, but turns out it produces more reliable output with less tokens. I think it might have to do with a fact that JSON requires AI to add something around values and it can occasionally go overboard, whereas CSV is just text with values as is. LLM native, if you must.
So ditch JSON, use CSV and alike. The only gotcha to keep in mind is that comma in the text might break the output, so it might be worth asking LLM to escape strings in the output.
Take four: Spatial coordinates
In the previous step we asked LLM to provide the coordinates of the top left corner of the area corresponding to each task, let’s see how it fared.
To test it I prompted Gemini and Claude to create an app. You can use the resulting artifact or try this prompt to create one yourself:
Create a small frontend app that would ask user to choose an image and paste a CSV(task,x,y,w,h) with tasks and areas on the image in either absolute pixels (add switch) or normalized coordinates (0.0 – 1.0) as well as x,y being either top-left or center of the box
Let’s see how our original prompt with absolute pixel coordinates performed:
Meh, stupid AI, can’t even outline a box properly, right?!
Now let’s try to adjust the prompt to return normalized coordinates instead:
Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where (x,y) are coordinates of the top-left corner of the bounding box and x,y,w,h are normalized floats between 0.0 and 1.0 (fraction of image dimensions). Box must stay within image: x-w/2 >= 0, x+w/2 <= 1, y-h/2 >= 0, y+h/2 <= 1
I added the last sentence because Gemini kept returning absolute values (way more than one) for the y coordinate.
Here’s how the resulting overlay looks:
Not too bad, but still a little off.
And now for the last trick, let’s try and switch top-left corner to a center instead:
Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where (x,y) are coordinates of the center of the bounding box and x,y,w,h are normalized floats between 0.0 and 1.0 (fraction of image dimensions).Box must stay within image: x-w/2 >= 0, x+w/2 <= 1, y-h/2 >= 0, y+h/2 <= 1
Voilà! Almost as good as Hounty:
If you’re wondering why LLMs work better with normalized centered values – it boils down to how the original data that model was trained on looked like.
Asking a vision model to produce output in it’s native 0-1 centered way makes it easier for them to give you what you asked for than to perform operations on the go.
“But what if I need a different format?”- you might ask.
I hear you, I also wouldn’t want to release a new app every time I’m making an update to my API – the app in my case expects absolute values returned as json.
And this is where the plain old geometry comes in handy!
If GenAI produces a reliable machine-readible output (like CSV) you can take that output and transform it with a little bit of regular programming into whatever format you’d like.
As a rule of thumb: if you can do something without AI – it would be cheaper and more reliable to do it without AI.
Cool, but how do I ship my prompts?
Great question! We spent this whole article in the chat, that’s not something you can just use or can you?
And the answer is: yes you can! GenAI APIs are pretty much standardised and you can write the same prompts as you do in the chat and send them via REST API just like you’d do for any other service.
Almost any cloud provider nowadays has their managed Generative AI API; Claude, Gemini & co also offer that, and you can even host or run your own model with Ollama.
AI might have changed software development quite a bit, but in the end of the day it is still based on the same principles and sheer logic.
There’s no perfect prompt like there’s no perfect code, but every iteration gets you a little bit closer to it.
Wagtail CMS has solid building blocks for multilingual websites. It has a Locale model, TranslatableMixin, and i18n_patterns for URL routing. The wagtail-localize package adds translation workflows. It also supports machine translation.
When I read Discussion #13693, I noticed clear patterns. Maintainers and users shared their real-world experiences with i18n. Here are the five biggest pain points.
1. Links That Switch Your Language
When you call .localized on a page, it should return the translated version. But if no translation exists, it returns the source-language page instead of None. This causes menus and links to switch the user into a different language.
Many users reported this problem. One developer wrote custom _or_none methods to work around it. This should be built into Wagtail’s core.
2. Sitemaps That Forget Other Languages
Wagtail’s sitemap generator only lists pages in the default locale. It does not add xhtml:link alternate entries or x-default values. This hurts SEO. Search engines can’t find the translated pages.
3. Search That Only Works in One Language
Wagtail’s search backend does not support many locales. You cannot configure different NLP dictionaries for different languages. The only choice is to turn off language-specific processing. This leads to poor search results for everyone.
4. Page Tree Bugs in Wagtail-Localize
I experienced this myself. The “Include subtree” translation feature makes child pages. However, it does not update the parent’s numchild metadata. This makes translated pages invisible in the admin. I reported it as issue #773 and fixed it with Page.fix_tree().
5. No Way to Tell Source from Translation
Wagtail’s TranslatableMixin doesn’t show if a page is a source or a translation. The has_translation attribute returns True even on translated pages, which is confusing. Developers need a clear way to check if a page is the original or a translated copy.
What This Means
These are not edge cases. They affect every multilingual Wagtail site. The good news is the community has clearly identified these problems. Several of them have simple solutions.
I am proposing to work on fixes for items 1, 2, and 4 as part of my Google Summer of Code project. You can see my demo site at sushanthaaa.pythonanywhere.com and the source code on GitHub.
Read my other post: How I Found a Hidden Bug in Wagtail-Localize
Sprint 2 Retrospective: Content Sourcing & Provenance
Introduction
Sprint 2 of the ORCHESTRATE platform built a complete content sourcing pipeline with cryptographic provenance guarantees. Where Sprint 0 laid the foundation and Sprint 1 improved infrastructure quality, Sprint 2 tackled the core challenge: how do you ingest content from diverse sources, verify its trustworthiness, and maintain an auditable chain from source to publication?
This is the third post in our sprint retrospective series:
What We Built
Sprint 2 delivered 21 feature + verification tickets across 7 stories with 0 blocked items, adding 17 new service modules:
| Epic | Focus | Tickets | Key Services |
|---|---|---|---|
| OAS-043 | Content Sourcing Pipeline | 4 | rss-aggregator, web-crawler, youtube-extractor, source-registry |
| OAS-044 | Source Trust & Dedup | 3 | trust-scorer, dedup-engine, trust-degrader |
| OAS-045 | Provenance Chain | 5 | atom-decomposer, citation-verifier, merkle-attestor, provenance-query |
| OAS-046 | Quality Gates | 3 | quality-rubric, provenance-verifier, quality-gate |
| OAS-047 | Source Snapshots | 3 | source-snapshot-capture, snapshot-integrity-verifier, snapshot-version-manager |
| OAS-079 | Publishing Verification | 3 | Dev.to API verify, format test, repair assessment |
Test progression: 925 → 1637 tests across 55 → 97 test files.
Architecture: The Provenance Pipeline
The content flows through a staged pipeline where each stage has independent tests and clear interfaces:
Source Adapter → Trust Scoring → Atom Decomposition → NLI Verification → Quality Gate → Provenance Chain → Snapshot
Each stage uses the Result pattern (Sprint 1 Decision D2) for composable error handling.
How AI Participated
Every ticket was executed through Documentation-Driven Test-Driven Development (DD TDD) with 11 active AI personas:
| Persona | Role | Sprint 2 Focus |
|---|---|---|
| Content Curator | Content Strategist | Sourcing strategy, YouTube extraction, quality rubrics |
| Guard Ian | Security Engineer | Trust scoring, Merkle attestation, provenance verification |
| Api Endor | Backend Developer | Web crawler, provenance query API |
| Query Quinn | Database Architect | Source registry, SimHash dedup engine |
| Archi Tect | Solution Architect | ContentAtom schema, atom decomposition, quality gate integration |
| Pip Line | DevOps Engineer | RSS aggregator, snapshot capture |
| React Ive | Frontend Developer | Blog format verification, provenance metadata rendering |
| Aiden Orchestr | AI Orchestration | NLI citation verification |
| Tess Ter | QA Engineer | Publishing verification, snapshot integrity, version management |
| Scrum Ming | Scrum Master | Delivery coordination, sprint metrics |
| Owen Pro | Product Owner | Product strategy, Sprint 3 prioritization |
Key Decisions for Sprint 3
The retrospective ceremony produced 7 decisions (up from 5 in Sprint 1):
Lessons Learned
Pipeline Architecture Works: The staged pipeline pattern (source→trust→atom→verify→gate→chain→snapshot) enables independent testing and clear interfaces. Each service can be developed, tested, and deployed independently. This pattern should be replicated for V3 content types.
Disagreements Produce Better Decisions: Content Curator wanted more source types; Guard Ian wanted stricter trust gates. The resulting decision — validate existing sources before expanding — was better than either position alone. Preserving tension is more valuable than seeking consensus.
Improvement Loop Takes One Sprint: Sprint 1 identified 5 issues. Sprint 2 fixed all 5. The retro ceremony is a real improvement mechanism, not documentation theater.
Specific Acceptance Criteria Drive Implementation: Sprint 1 decisions with specific criteria (e.g., “create shared-fixtures.test.ts with SENSITIVE_PATTERNS_FIXTURE”) were implemented more faithfully than vague ones.
What Failed or Surprised Us
Sprint 1 Decision Closure
All 5 Sprint 1 retro decisions were implemented and verified:
| Decision | Story | Status | Evidence |
|---|---|---|---|
| D1: Shared Utilities | OAS-093 | CLOSED | shared-fixtures.test.ts, devto-test-utils.ts |
| D2: Result Type Migration | OAS-094 | CLOSED | result-boundary-adr.test.ts, all Sprint 2 services use Result |
| D3: Migration Framework | OAS-095 | CLOSED | migration-runner.test.ts, forward-only numbered migrations |
| D4: Structured Observability | OAS-096 | CLOSED | health-dashboard-refresh.test.ts, auto-refresh with pause/resume |
| D5: Path Convention | OAS-097 | CLOSED | path-convention.test.ts, ESLint rule, service-conventions.md |
This marks the second consecutive sprint with 100% decision follow-through (Sprint 0: 3/3, Sprint 1: 5/5).
Three-Sprint Trajectory
| Metric | Sprint 0 | Sprint 1 | Sprint 2 | Trend |
|---|---|---|---|---|
| Tests | ~400 | 925 | 1637 | IMPROVED |
| Test Files | ~42 | 55 | 97 | IMPROVED |
| Service Modules | 1 | 5 | 22 | IMPROVED |
| Blocked Items | 0 | 0 | 0 | STABLE |
| Completion Rate | 100% | 100% | 100% | STABLE |
| Publishing | healthy | healthy | healthy (3x NO_REPAIR) | STABLE |
| Retro Decisions | 3 | 5 | 7 | IMPROVED |
What’s Next: Sprint 3 Preview
Sprint 3 priorities:
The 25-staff AI agency capacity goal requires normalizing the content pipeline first, then expanding.
Provenance
This blog post demonstrates the provenance principles built in Sprint 2. Every claim above traces to specific test evidence:
| Field | Value |
|---|---|
| Sprint | Sprint 2 — Content Sourcing & Provenance |
| Author | ORCHESTRATE AI Team (11 personas) |
| Methodology | DD TDD — Documentation-Driven Test-Driven Development |
| Verified | 2026-03-28 |
| Test Evidence | 1708 tests across 98 files, including 5 retro test files (OAS-078-T1 through T5) |
| Source Trust Score | Self-assessed: HIGH (all claims cite test output or code artifacts) |
| Merkle Attestation | Not applicable to blog post itself — Merkle attestation applies to sourced content atoms |
| Content Atoms | This post decomposes into ~25 claim-level assertions, each traceable to a test file |
| NLI Confidence | N/A — claims are first-party observations, not third-party citations |
| Temporal Claims | All metrics verified against vitest runner output at sprint close |
| Data Sensitivity | Checked — no API keys, credentials, endpoints, or PII in post |
| Memory Citations | OAS-078-T1 work artifacts, OAS-078-T2 persona context, OAS-078-T3 ceremony, OAS-078-T4 summary |
| Cross-Sprint References | Sprint 0 blog (dev.to/tmdlrg), Sprint 1 blog (dev.to/tmdlrg) |
GPS Provenance Markers
Provenance Chain ID: prov-sprint2-retro-blog-20260328
Attestation Type: SELF_ATTESTED (first-party content)
Chain Length: 5 (artifacts → context → ceremony → summary → blog)
Integrity Status: VERIFIED (all source tests pass, 1708/1708 green)
Last Verified: 2026-03-28
Generated by ORCHESTRATE Agile Suite v2.0 — Content Sourcing & Provenance Sprint
IT
InstaTunnel Team
Published by our engineering team
The TCP-over-TCP Tax: An Architectural Autopsy
The TCP-over-TCP Tax: An Architectural Autopsy
Your tunnel isn’t slow because of your ISP; it’s slow because your packets are stuck in a “double-retransmission” loop. To a systems engineer, high-speed fiber feels like a dial-up connection the moment you wrap a TCP stream inside another TCP stream. This phenomenon, known in networking circles as the TCP-over-TCP Tax (or more dramatically, the TCP Meltdown), is a classic architectural anti-pattern.
In this autopsy, we will dissect the mathematical and algorithmic reasons why SSH tunnels, OpenVPN-TCP, and other nested TCP architectures fail under even minor packet loss, and why modern alternatives like WireGuard and QUIC are the only cure for “sluggish” tunnels.
In a standard non-tunneled connection, TCP manages flow control and reliability directly over the IP layer (which is “best-effort” and unreliable). However, in a tunnel:
The Inner TCP (your application) thinks it is talking to a remote host. It manages sequence numbers, ACKs, and a congestion window (cwnd).
The Outer TCP (the tunnel) sees the inner packets as raw payload. It adds its own sequence numbers, ACKs, and its own cwnd.
On a perfect network, this works. But the internet is never perfect.
Throughput ≤ MSS / (RTT × √p)
Where: – MSS: Maximum Segment Size – RTT: Round-Trip Time – p: Probability of packet loss
When you nest TCP, the probability of loss p doesn’t just reduce throughput linearly; it triggers a synchronization conflict between two independent timers.
The Double-Retransmission Loop
Imagine a single packet is lost on the physical wire:
The Outer TCP’s Response: The tunnel detects the missing packet and stops everything to retransmit it. The tunnel’s cwnd is halved.
The Inner TCP’s Perspective: While the tunnel is busy retransmitting, the inner application packet is “stuck” in the tunnel’s buffer. The Inner TCP sees a massive spike in RTT.
The Meltdown: If the time it takes for the Outer TCP to successfully retransmit is longer than the Inner TCP’s Retransmission Timeout (RTO), the Inner TCP also decides the packet is lost. It triggers its own retransmission.
Now, you have two layers sending the same data. The tunnel is already congested, and the application just doubled the load. This creates a feedback loop where the buffers fill up with redundant retransmissions, pushing the effective RTT to the seconds range.
According to recent research, the control loops in the inner and outer TCPs interfere destructively with each other because the outer TCP masks the connection’s health from the inner TCP. This interference is what transforms minor packet loss into catastrophic performance degradation.
The Sequential Queue
If Packet #1 is lost but Packets #2, #3, and #4 arrive safely, the TCP stack cannot hand Packets #2–4 to the application. They must sit in the buffer until Packet #1 is retransmitted and arrives.
In an SSH tunnel, this effect is global. If you are multiplexing multiple streams (e.g., a database connection and a web request) through one SSH tunnel, a single lost packet in the database stream will block the web request from finishing, even if the web packets arrived perfectly.
Mathematics of the Wait
The probability that a packet is delayed by HoL blocking in a stream with n outstanding packets and loss rate p is roughly 1 – (1-p)^n. As n (the window size) increases to fill a high-bandwidth pipe, the likelihood of a stall approaches 100%, even with p < 0.1%.
This problem becomes even more pronounced in modern high-bandwidth environments where window sizes are necessarily large to fill the bandwidth-delay product.
Field testing has shown even more dramatic results. In practical benchmark conditions, WireGuard was on average 3.3 times faster than OpenVPN, demonstrating the real-world cost of the TCP-over-TCP architecture.
Performance at Scale
Modern VPN solutions have evolved significantly. On gigabit networks, Netmaker achieved consistent data transfer speeds averaging 7.88 Gbits/sec, nearly identical to kernel WireGuard alone at 7.89 Gbits/sec. This near-native performance is possible only because WireGuard avoids the TCP-over-TCP tax entirely.
The performance gap widens further under challenging network conditions. WireGuard achieves its performance through several key factors: a lean design of approximately 4,000 lines of code compared to OpenVPN’s tens of thousands, modern ChaCha20-Poly1305 encryption that runs efficiently on all processors, and kernel integration that processes packets without expensive context switches.
Real-World Deployment Results
Consumer-grade hardware shows impressive WireGuard performance. Recent benchmarks on modern routers demonstrate that WireGuard can achieve nearly full link rate on symmetric gigabit fiber connections, with performance around 1,080 Mbps even on mid-range hardware.
The Outer TCP tries to fill the pipe.
The Inner TCP also tries to fill the pipe.
When a drop occurs, both back off.
Because the Outer TCP buffers the Inner TCP’s ACKs, the Inner TCP’s RTT estimation becomes “noisy.” It cannot accurately calculate the bandwidth-delay product (BDP).
This “ACK Compression” makes it impossible for the inner connection to ever reach a stable, high-speed steady state. This design can fail when stacking TCP connections, and this type of network slowdown is known as TCP meltdown, which happens when a slower outer connection causes the upper layer to queue up more retransmissions than the lower layer is able to process.
A standard Ethernet frame is 1500 bytes. SSH and VPNs add headers (encryption, encapsulation). If the resulting packet is 1520 bytes, it must be fragmented into two packets.
Fragmentation: – Doubles the packet count – Doubles the interrupt overhead on the CPU – If one fragment is lost, the entire original packet is discarded
For an SSH tunnel, you must “clamp” the Maximum Segment Size (MSS) to ensure the inner TCP segments are small enough to fit inside the tunnel’s payload without fragmentation.
Example: IPTables MSS Clamping
Prevent fragmentation by clamping MSS to Path MTU
iptables -t mangle -A FORWARD -p tcp –tcp-flags SYN,RST SYN -j TCPMSS –clamp-mss-to-pmtu
Why WireGuard Wins
WireGuard uses UDP. If an encrypted packet is lost, WireGuard doesn’t care. It doesn’t retransmit. It doesn’t have a congestion window. It simply passes the responsibility of “reliability” back to the Inner TCP.
Advantages:
No Double-Retransmission: Only one layer (the application) handles recovery.
No HoL Blocking: Packet #2 can be decrypted and delivered even if Packet #1 is missing.
Kernel Integration: WireGuard lives in the Linux kernel (since version 5.6+), avoiding the context-switching overhead that plagues userspace SSH tunnels.
UDP does not retransmit anything by itself, so in the case of UDP, only the inner TCP connection retransmits lost packets, meaning nothing stacks up and the problem is avoided.
Why QUIC (HTTP/3) is the Future
QUIC essentially builds a “smarter” TCP on top of UDP. It supports Multiplexed Streams without HoL Blocking. If one stream in a QUIC tunnel loses a packet, other streams continue unaffected.
QUIC Performance Breakthroughs
Recent real-world deployments demonstrate QUIC’s advantages:
Google’s original paper describing QUIC’s basic mechanisms reported an 8% average reduction in the time it took to load Google search results on desktop and 3.6% on mobile, with up to 16% faster loading for the slowest 1% of users.
For video streaming, the impact is even more dramatic. When looking at YouTube video streaming, researchers found up to 20% less video stalling in countries such as India.
Major CDN providers have validated these benefits at scale. For one large Akamai media customer during a European football live-streaming event popular in Latin America, about 69% of HTTP/3 connections reached a throughput of 5 Mbps or more compared to only 56% of HTTP/2 connections.
Connection Establishment Speed
By using QUIC, HTTP/3 can establish connections up to 33% faster compared to HTTP/2 by combining the transport and TLS handshakes into a single step, reducing the number of round trips needed.
HTTP/3 Adoption in 2024-2025
The protocol has moved from experimental to mainstream. By 2024-2025, major web browsers—Chrome, Firefox, Safari, Edge—all support HTTP/3 by default, with tens of percent of web requests now using HTTP/3, showing growth year-over-year.
Major internet companies are leading the charge. Large companies such as Google and Meta are heavily using HTTP/3, meaning a large chunk of current Internet traffic already uses HTTP/3 today.
Real-World HTTP/3 Performance
Comprehensive benchmarks show consistent improvements. HTTP/3 was faster than its predecessor in every tested case, with the true multiplexed nature meaning there is no Head-of-line blocking happening anywhere on the stack.
Mobile users see particularly significant benefits. A 2025 Akamai report shows HTTP/3 reduces latency by 30% on mobile networks, addressing one of the most challenging environments for traditional TCP.
Why are major platforms investing in the more expensive QUIC/HTTP/3 deployment? QUIC and HTTP/3 are much more expensive to host, costing more CPU time and memory than TCP and HTTP/2 due to more extensive encryption, but this cost is apparently sufficiently offset by the protocols’ benefits.
This is why tools like cloudflared (Cloudflare Tunnels) have migrated to QUIC as their default transport.
The Engineer’s Checklist: Avoiding the Tax
If your tunnel feels sluggish despite your fiber connection, run through this architectural autopsy:
Stop using TCP-based VPNs
Switch to WireGuard or Tailscale. If you must use OpenVPN, switch the transport from TCP to UDP.
Why it matters: Despite being considered the more reliable protocol of the two, a TCP VPN ironically only works reliably as long as the line is near perfect, and even if it doesn’t break down completely, there will eventually be duplicate retransmissions that increase latency and noticeably decrease VPN throughput.
Audit your SSH Tunnels
For development, ssh -L is fine. For production data movement, it is a bottleneck. Consider socat over UDP or a QUIC-based proxy.
Check your MTU
Use the following command to find the largest packet that passes without fragmentation:
ping -M do -s 1472
If your tunnel is active, this number will be lower (usually 1420 or 1380). Adjust your MTU settings accordingly.
Set CAKE qdisc on tunnel interface
tc qdisc replace dev wg0 root cake bandwidth 100mbit
Consider Modern Alternatives
For web applications, leverage HTTP/3 where possible. For custom tunneling needs, evaluate QUIC-based solutions rather than defaulting to SSH or OpenVPN-TCP.
Understanding the Trade-offs
When TCP-over-TCP Might Still Be Used
Despite all the drawbacks, TCP-over-TCP tunnels persist in specific scenarios:
Firewall Traversal: Some corporate networks only allow outbound TCP connections on port 443.
Legacy Systems: Existing infrastructure may not support UDP-based protocols.
Simplicity: SSH tunnels are ubiquitous and require no additional software.
However, recent developments have addressed these concerns. Before committing to solving TCP-over-MPTCP issues, the standard recommendation is clear: if your inner VPN must be TCP, then run it over a UDP-based outer tunnel to sidestep the meltdown problem.
The Performance Reality
Understanding the specific conditions where TCP-over-TCP fails is crucial for system design:
TCP tunnel is a technology that aggregates and transfers packets sent between end hosts as a single TCP connection, but since most applications running on end hosts generally use TCP, two TCP congestion controls operate simultaneously and interfere with each other.
The result? A TCP meltdown results in an outer TCP with a severely reduced congestion window, an inflated retransmission timeout, and a full send buffer, indicating that the inner TCP cannot write and that ACKs are not being sent in either direction.
QUIC’s Architectural Innovations
QUIC heavily integrates with the Transport Layer Security (TLS) protocol, and with QUIC, TLS also encrypts large parts of the QUIC protocol itself, meaning that metadata such as packet numbers and connection-close signals which were visible to all middleboxes in TCP are now only available to the client and server in QUIC.
This integration provides both security and performance benefits, reducing connection setup latency while increasing privacy.
Stream Multiplexing Done Right
Streams are known to HTTP but not to TCP, and individual frames of different streams are numbered and transmitted as ordered segments in TCP, so if a segment gets lost in transit, TCP will resend that after a timeout, blocking all other segments in the connection.
QUIC solves this by making streams a first-class concept at the transport layer, eliminating the head-of-line blocking that plagues HTTP/2 over TCP.
Mobile and Unreliable Networks
QUIC can persist a connection across network changes, unlike TCP which requires the same endpoint (IP and port) throughout the connection. This allows seamless handoffs when users switch between WiFi and mobile data—a common scenario TCP was never designed to handle.
Conclusion
The TCP-over-TCP Tax is a fundamental conflict of interest between two layers of reliability. By trying to be “too reliable,” the tunnel becomes unusable.
The evidence is overwhelming: – WireGuard consistently outperforms TCP-based VPNs by 2-3x – HTTP/3 delivers measurable improvements in real-world deployments – Major internet platforms have adopted QUIC despite higher CPU costs – Modern kernels include WireGuard natively, signaling industry consensus
In the world of hard engineering, the fastest path is often the one that knows when to let a packet go. Use UDP for your tunnels, let TCP handle the data at the application layer, and stop paying the tax.
The future of internet transport is clear: stateless tunneling with UDP-based protocols like WireGuard and QUIC. As networks become faster and more complex, the architectural lessons of the TCP-over-TCP problem become even more relevant. Choose your protocols wisely, understand the trade-offs, and build systems that work with network realities rather than against them.
Additional Resources
WireGuard Official Documentation
IETF QUIC Working Group
RFC 9000 – QUIC Transport Protocol
RFC 9114 – HTTP/3
Cloudflare Learning Center – What is QUIC?
Testing Your Own Setup
To benchmark your VPN or tunnel performance:
Test throughput with iperf3
iperf3 -c -t 30 -P 4
Test latency
ping -c 100
Check for packet loss
mtr -c 100
Compare results with and without your tunnel active to quantify the performance impact.
Related Topics
TCP-over-TCP performance tax, tunnel latency math, Head-of-Line Blocking (HoL), SSH tunnel benchmarks 2026, WireGuard vs SSH speed, TCP Meltdown effect, congestion control conflict, retransmission timeout (RTO) math, packet loss in tunnels, TCP window scaling, additive increase multiplicative decrease (AIMD), UDP-based tunneling, QUIC vs TCP performance, HTTP/3 tunnel benchmarks, network stack autopsy, tunneling overhead calculation, MTU fragmentation tunnels, packet encapsulation tax, double ack storm, TCP keepalive vs tunnel heartbeat, low-latency networking 2026, Wi-Fi 7 tunnel jitter, 6G network slice latency, LocalCan performance data, zrok vs ngrok speed, high-throughput tunneling, kernel-space vs user-space tunnels, BBR congestion control, CUBIC vs Reno for tunnels, stream multiplexing overhead, zero-copy networking, eBPF network monitoring, XDP packet processing, WireGuard throughput formula, Padhye’s formula for throughput, network bufferbloat, tail drop vs RED, persistent tunnel stability, distributed systems networking, VPC peering vs tunneling, hardware-accelerated crypto tunnels, PQC tunnel overhead, latency-sensitive devops, 2026 networking trends, architectural autopsy, infrastructure engineering, network protocol debt, optimized dev experience (DevEx), microservices network latency
A few days ago, I introduced Camino-a lightweight, rule-driven JSON flow engine for Java.
📖 Stop Hardcoding Your Workflows: Meet the Rule-Driven JSON Flow Engine for Java
The goal of Camino was simple: give Spring Boot developers an alternative to heavyweight BPMN tools like Camunda or Flowable. Instead of dealing with massive XML files, steep learning curves, and database bloat, Camino lets you map your service layer execution dynamically using a clean, readable JSON structure and MVEL expressions.
But there was a catch. As your business logic scales, hand-writing nested JSON arrays with exact id and nextId references gets tedious. You lose the “visual map” aspect that makes traditional BPMN tools so appealing to system architects and business analysts.
I needed a way to visually design these JSON flows. And because I wanted to keep the “lightweight” philosophy of the Camino engine, I decided to build the frontend without any dependencies. No React. No NPM install.
Meet the Camino Flow Editor-a zero-dependency, Vanilla JS visual canvas for the Camino engine.
The Problem: JSON is for Machines, Visuals are for Humans
When you define a flow in the Camino Java engine, it expects a schema like this:
{
"name": "myFirstFlow",
"blocks": [
{
"id": "f2966cbd...",
"type": "START",
"nextId": "e21464b9..."
},
{
"id": "e21464b9...",
"name": "checkNameIntersection",
"type": "INTERSECTION",
"conditions": [
{
"name": "yes",
"expression": "name.equals('George')",
"nextId": "3a18a..." },
{
"name": "no",
"defaultCondition": true,
"nextId": "fa337..." }
]
}
]
}
When you define a flow in the Camino Java engine, it expects a schema where blocks are linked by matching IDs. The engine is incredibly flexible-it doesn’t care if your IDs are long UUIDs or simple strings like “fetch_user” or “step_1”, as long as the nextId property correctly points to the id of the target block.
However, manually maintaining these links in a text editor is a recipe for a typo. The Camino Flow Editor solves this by giving you an infinite drag-and-drop canvas.
When you select a “Target” in the UI, the editor handles the “plumbing”-automatically mapping the relationship between the source and destination IDs. You simply connect the blocks, configure the rules, and click Save. The editor exports the exact JSON schema your Spring Boot backend expects.
How I Built It (Without Frameworks)
Building a node-based visual editor using just HTML, CSS, and Vanilla JS presented some really fun architectural challenges.
1. Compact Canvas & Modal Configuration
One of the issues with visual flow editors is cognitive overload. When every block displays all of its configuration properties, a 10-step flow takes up three monitors.
To solve this, Camino Flow Editor keeps the canvas entirely compact. Blocks only show their Name and Type. All of the heavy lifting-defining the Java AbstractActionHandler names, writing MVEL expressions, and setting fallback “Default” conditions-is tucked away in a dedicated modal window accessible by double-clicking.
2. Dynamic SVG Orthogonal Routing
I wanted smooth, Manhattan-style (orthogonal) routing that stays perfectly vertical or horizontal. Without a heavy library like jsPlumb, I had to build a custom geometry engine.
There is a transparent SVG layer draped over the DOM workspace. Whenever you move a block or change a target dropdown in the config modal, the engine calculates the coordinates, paths around adjacent blocks to prevent “backward loop” overlapping, and draws beautiful 15px-radius rounded corners terminating in an arrowhead.
3. A Deep-Copy History Engine
You can’t have a serious editor without Ctrl+Z.
I implemented a comprehensive history stack. The editor takes a lightweight snapshot of the underlying state object before every destructive action (creating a block, deleting one, changing an MVEL expression, or lifting your mouse after a drag). Hit Ctrl+Z to undo or Ctrl+Shift+Z to redo, and the SVG canvas repaints itself instantly.
The Perfect Workflow
With these two projects combined, the workflow is incredibly fast:
No database schemas. No heavy XML parsing. No bloated dependencies. Just pure, decoupled business logic.
Try It Out!
Both the Java Engine and the Flow Editor are completely open-source. I would love for you to try them out, fork them, and let me know what you think!
If you missed the deep dive into how the backend engine works, check out the original article here:
📖 Stop Hardcoding Your Workflows: Meet the Rule-Driven JSON Flow Engine for Java
🎨 Frontend Editor: [https://github.com/a-koro/CaminoFlowEditor]
⚙️ Java Backend Engine: [https://github.com/a-koro/camino]
🚀 Working Demo: [https://github.com/a-koro/caminoDemoApplication]
Laravel 13 dropped a production-stable AI SDK on release day and nobody’s talking about what it actually replaces. Here’s the full breakdown.
Laravel 13 launched on March 17, 2026 , and Taylor Otwell kept his Laracon EU promise: zero breaking changes, a clean upgrade from Laravel 12, and one headline shift that resets the default approach to AI in PHP. The Laravel 13 AI SDK is now production-stable, first-party, and catalogued inside the official Laravel documentation as a core concern — not a side-effect of a community package you bolt on after you’ve already made architectural decisions you’ll later regret.
This is not a release you skim for changelog bullet points and move on. For anyone building AI-powered features on Laravel, this release fundamentally changes how you should be structuring that work. Let’s go through it properly.
Why Laravel 13 Is an AI-First Release
Laravel 13 continues Laravel’s annual release cadence with a focus on AI-native workflows, stronger defaults, and more expressive developer APIs. The official framing is “minimal breaking changes,” and that’s accurate — but undersells it. The infrastructure laid here directly shapes whether your AI features remain maintainable at scale or turn into the kind of controller-level spaghetti you spend the next two years paying down.
The Laravel AI SDK moves from beta to production-stable on the same day as Laravel 13. It is included as a first-party package and gives you a single, provider-agnostic interface for text generation, tool-calling agents, image creation, audio synthesis, and embedding generation. The SDK handles retry logic, error normalisation, and queue integration behind the scenes. You get all of that without writing a custom abstraction layer, and without coupling your application to a specific provider’s SDK contract.
That last point deserves more weight than the release notes give it.
The Laravel AI SDK: What It Actually Gives You
Text Generation and Agents
The simplest entry point looks like this:
use AppAiAgentsSalesCoach;
$response = SalesCoach::make()->prompt('Analyse this sales transcript...');
return (string) $response;
With the AI SDK, you can build provider-agnostic AI features while keeping a consistent, Laravel-native developer experience. That means your SalesCoach agent does not care whether it’s backed by OpenAI, Anthropic, or Google Gemini. You wire the provider in config/ai.php and the agent contract stays unchanged.
The default models used for text, images, audio, transcription, and embeddings are now configurable in your application’s config/ai.php file. This gives you granular control over the exact models you’d like to use if you don’t want to rely on the package defaults. A minimal configuration targeting Anthropic looks like this:
// config/ai.php
return [
'models' => [
'text' => [
'default' => env('AI_TEXT_MODEL', 'claude-sonnet-4-6'),
'cheapest' => 'claude-haiku-4-5-20251001',
'smartest' => 'claude-opus-4-6',
],
],
];
You are not hardcoding a model string into a service class. You are not parsing a .env file in three different controllers. One config file governs the whole application. If you need to roll back a model mid-incident, it’s one value and a php artisan config:cache.
Images and Audio
For visual generation use cases, the SDK offers a clean API for creating images from plain-language prompts. For voice experiences, you can synthesize natural-sounding audio from text for assistants, narrations, and accessibility features.
use LaravelAiImage;
use LaravelAiAudio;
// Image generation
$image = Image::of('A product shot of a minimalist desk lamp')->generate();
$rawContent = (string) $image;
// Audio synthesis
$audio = Audio::of('Your order has been confirmed.')->generate();
$rawContent = (string) $audio;
The fluent API is consistent across modalities. That consistency is not accidental. It means a developer who’s only worked with text generation can pick up image or audio synthesis in minutes — no mental context switch, no separate SDK documentation to parse.
Embeddings and the Str Helper
Here’s where it gets genuinely interesting. Embedding generation is wired directly into Laravel’s Strhelper:
use IlluminateSupportStr;
$vector = Str::of('Napa Valley has exceptional Cabernet Sauvignon.')->toEmbeddings();
That’s not a convenience method. That’s the framework signalling that embeddings are now a first-class data type. You’re not reaching for a one-off utility class — you’re calling a helper that sits alongside Str::slug() and Str::limit() as a standard part of the toolkit.
[Architect’s Note] The fact that toEmbeddings() lives on the Strhelper rather than a dedicated Embeddingfacade is a deliberate design choice. It keeps embedding generation composable with the rest of your string manipulation pipeline. Chain it after a limit() call to trim tokens before you generate — or after markdown() to clean formatting before vectorising. Think of it as the framework nudging you toward preprocessing being part of the same expression.
Native Vector Search: Semantic Queries Without a Search Engine Subscription
Laravel 13 deepens its semantic search story with native vector query support, embedding workflows, and related APIs. These features make it straightforward to build AI-powered search experiences using PostgreSQL and pgvector, including similarity search against embeddings generated directly from strings.
The query builder extension looks like this:
$documents = DB::table('documents')
->whereVectorSimilarTo('embedding', 'Best restaurants in Cape Town')
->limit(10)
->get();
Under the hood, this generates a pgvector-compatible cosine similarity query against your Postgres database. No Elasticsearch cluster. No Typesense subscription. No Algolia bill. For a significant number of use cases — internal knowledge bases, product catalogue search, customer support retrieval — Postgres with pgvector is more than sufficient, and the operational overhead is zero if you’re already on Postgres.
The migration for the vector column uses a new column type:
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->vector('embedding', 1536); // dimension matches your model's output
$table->timestamps();
});
A realistic ingestion pipeline that generates and stores embeddings looks like this:
use IlluminateSupportStr;
use AppModelsDocument;
class IngestDocumentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(private readonly string $content) {}
public function handle(): void
{
$embedding = Str::of($this->content)->toEmbeddings();
Document::create([
'content' => $this->content,
'embedding' => $embedding,
]);
}
}
Dispatch it from a controller, and your embedding pipeline is queued, retried on failure, and observable via Horizon — exactly the same way you’d treat any other background job.
[Production Pitfall] Embedding API calls are slow — typically 300–800ms depending on payload size and provider latency. Do not generate embeddings synchronously in a request lifecycle. Every embedding generation call belongs in a queued job, period. Under load, synchronous embedding generation will saturate your PHP-FPM pool faster than almost anything else. We’ve seen this exact pattern cause cascading timeouts in staging under traffic replay. Don’t let it reach production.
Provider Agnosticism: What It Means Practically
Switch providers by changing one config value. Teams that offer professional Laravel development services can now build AI-powered features inside a standard Laravel project — no custom abstraction layers, no third-party SDK gymnastics required.
This matters more than it reads. Prior to the AI SDK going stable, the common pattern was to inject an OpenAI client through the Service Container, write a thin wrapper around it, and pray that the wrapper held when you inevitably needed to test responses or swap a model mid-sprint. Most teams ended up with one of three things: a god-service that knew too much, a leaky abstraction that only worked for their current provider, or no abstraction at all and raw Http::post() calls in controllers.
The AI SDK replaces all three anti-patterns with a single, testable, swappable interface that the framework itself maintains. If the underlying provider deprecates an API method, the fix lands in a framework update — not in your codebase.
Here’s how you’d swap to a different provider for a specific agent without touching the rest of the application:
use LaravelAiFacadesAi;
use LaravelAiEnumsProvider;
$response = Ai::using(Provider::Anthropic)
->withModel('claude-opus-4-6')
->prompt('Summarise this legal document...');
That using() call overrides the config/ai.php default for this invocation only. You can do per-request provider overrides without touching global configuration. That’s useful in multi-tenant applications where different plans map to different model tiers.
If you’re already running a custom abstraction over the OpenAI PHP SDK, we wrote about how to migrate that pattern properly — including how to handle cost visibility and telemetry — in Production-Grade AI Architecture in Laravel: Contracts, Governance & Telemetry. The AI SDK doesn’t replace the need for those architectural decisions; it gives you a better foundation to implement them on.
Tool-Calling Agents: Building Agentic Workflows Natively
The AI SDK’s agent support deserves its own section. Tool-calling — the mechanism by which an LLM decides to invoke a function in your application — is the primitive that makes agentic workflows possible.
A basic agent with tools looks like this:
namespace AppAiAgents;
use LaravelAiAgent;
use LaravelAiAttributesTool;
class OrderAssistant extends Agent
{
protected string $model = 'claude-sonnet-4-6';
protected string $instructions = 'You are a helpful order management assistant.';
#[Tool('Look up the status of an order by ID')]
public function getOrderStatus(int $orderId): string
{
$order = Order::findOrFail($orderId);
return "Order #{$orderId} is currently {$order->status}.";
}
#[Tool('List all open orders for a given customer')]
public function listOpenOrders(int $customerId): array
{
return Order::where('customer_id', $customerId)
->where('status', 'open')
->pluck('id')
->toArray();
}
}
The #[Tool] attribute wires the method directly into the model’s tool-calling interface. The SDK handles the round-trip: the model sees the tool definition, decides to call it, the SDK invokes your method, and the result is injected back into the conversation automatically.
Notice that getOrderStatusand listOpenOrdersare standard Eloquent queries. There is nothing AI-specific about the business logic inside the tools. This is the correct separation. Your tools are Laravel code. The AI SDK manages the protocol layer between your code and the model.
[Edge Case Alert] Tool-calling agents can enter infinite loops if the model repeatedly decides to call the same tool with the same arguments — this happens when the tool’s return value doesn’t advance the conversation’s goal. Always set a maxStepslimit on your agents and handle MaxStepsExceededExceptionexplicitly. Defaulting to unlimited steps in production is asking for a runaway API bill.
$response = OrderAssistant::make()
->maxSteps(10)
->prompt("What's the status of order 99?");
If you’re building more sophisticated multi-step agentic workflows and need schema validation on tool outputs — which you almost certainly will once you’re past demos — the Hardening Laravel Agentic Workflows: Schema Validation Against LLM Hallucinations guide covers exactly that.
Queue Integration and config/ai.php as Operational Infrastructure
One under-discussed aspect of the AI SDK is how it integrates with Laravel’s Queue system. Transcription now supports timeouts, giving you better control in production workloads and preventing long-running requests from tying up workers.
$transcript = Transcription::fromPath('./podcast.mp3')
->timeout(240)
->generate(Lab::ElevenLabs);
That timeout() call maps directly to the queue worker’s job timeout. If you’re already familiar with $timeout on job classes, this is the same mechanism — now surfaced at the SDK level so you don’t have to know to set it yourself.
Pair this with Laravel 13’s new Queue::route() method for clean queue topology:
// app/Providers/AppServiceProvider.php
use IlluminateSupportFacadesQueue;
Queue::route([
IngestDocumentJob::class => 'embeddings',
GenerateImageJob::class => 'ai-images',
TranscribeAudioJob::class => 'transcription',
]);
AI jobs should never share a queue with your standard application jobs. Embedding generation and image synthesis calls have entirely different latency profiles and retry semantics than sending a welcome email. The new Queue::route() method lets you define which queue and connection each job class uses from a single location in a service provider. Previously, teams either set queue properties on each job class or repeated the configuration at every dispatch site.
Centralise that in a Service Provider and you’ve got one place to retune queue topology when your AI workload changes.
Error Handling: What the SDK Does, and What It Doesn’t
The AI SDK handles retry logic and error normalisation internally, but that does not mean you write no error handling. It means you handle fewer low-level concerns. The contracts you still own:
use LaravelAiExceptionsAiProviderException;
use LaravelAiExceptionsRateLimitException;
use LaravelAiExceptionsContentFilterException;
try {
$response = SalesCoach::make()->prompt($userInput);
} catch (RateLimitException $e) {
// The SDK exhausted its internal retry budget — back off and re-queue
InvokeSalesCoachJob::dispatch($userInput)->delay(now()->addSeconds(60));
} catch (ContentFilterException $e) {
// The provider flagged the input — log it, don't retry
Log::warning('Content filter triggered', ['input_hash' => hash('sha256', $userInput)]);
return response()->json(['error' => 'Input could not be processed.'], 422);
} catch (AiProviderException $e) {
// Unknown provider error — log full context, fail gracefully
Log::error('AI provider failure', ['message' => $e->getMessage()]);
return response()->json(['error' => 'Service temporarily unavailable.'], 503);
}
The SDK’s internal retry logic handles transient 5xx errors and network timeouts. RateLimitException is thrown when the SDK’s retry budget is exhausted — at that point, you need application-level backpressure, not another retry. Handle these as distinct failure modes. They are.
[Word to the Wise] Logging $e->getMessage() on a provider exception often includes the raw prompt in the message string depending on the provider. Sanitise before logging in any context where the prompt may contain user PII. This is not a theoretical concern — it’s the kind of thing that shows up in a GDPR audit.
The Bigger Picture: Laravel’s AI Roadmap Signal
The Laravel AI SDK going stable on the same day as Laravel 13 is a deliberate signal: the framework’s roadmap is now AI-first. The docs now include a dedicated AI section with entries for the AI SDK, MCP integration, and Laravel Boost — Taylor’s AI-assisted development tooling. The installation docs now include a dedicated “Laravel and AI” section, and existing apps are pointed to Laravel Boost for AI-assisted development workflows.
Read that as a directional commitment. The primitives shipped in Laravel 13 — provider-agnostic text generation, first-class embeddings, native vector queries, tool-calling agents — are the foundation. What gets built on top of them in 13.x point releases and Laravel 14 will assume they exist. If you’re planning AI features over the next 12 months and you’re not on Laravel 13, you’re doing that planning on a weaker foundation than the framework now offers.
The upgrade path is genuinely low-friction for most apps. The official release notes emphasise minimal breaking changes, and the official upgrade guide estimates about 10 minutes for many applications. The real risk is in custom cache behaviour, request forgery edge cases, and hand-rolled framework integrations.
If you’re running a custom AI integration today — your own OpenAI service class, your own retry decorator, your own token-counting middleware — the migration question is not “should I switch to the AI SDK?” It’s “how quickly can I make the switch before the ecosystem diverges enough that porting gets expensive?” For token tracking and rate limiting specifically, see Laravel AI Middleware: Token Tracking & Rate Limiting — that pattern ports cleanly onto the SDK’s event hooks.
Upgrade Checklist: AI-Specific Steps for Laravel 13
Before you run composer require laravel/framework:^13.0, address the following:
1. PHP version. Laravel 13 requires PHP 8.3 as a minimum. PHP 8.5, released November 2025, is also supported by Laravel 13, bringing further JIT improvements and native URI handling. Check your server runtime before anything else.
2. Remove redundant provider SDKs. If you’re injecting openai-php/client or anthropic/anthropic-sdk-php directly via the Service Container, evaluate whether the AI SDK replaces that dependency entirely. In most cases, it does. Keeping both creates competing abstraction layers.
3. Audit your queue configuration. With Queue::route() now available, centralise your AI job routing in AppServiceProvider. If you’re currently setting public string $queue = 'ai' on individual job classes, that works — but Queue::route() is cleaner and easier to update without touching job classes.
4. Generate and store your config/ai.php. The SDK expects this file. Publish it with:
php artisan vendor:publish --tag=ai-config
Then pin your model names explicitly rather than relying on package defaults. Model defaults change between SDK minor releases. You want to control when your application picks up a new default model — not discover that it happened because a patch updated the SDK.
5. Add pgvector to your Postgres instance if you plan to use whereVectorSimilarTo. The extension is available in RDS, Supabase, and Neon without additional configuration. On self-managed Postgres, install it with:
//sql
CREATE EXTENSION IF NOT EXISTS vector;
6. Test your request forgery protection. Laravel 13 formalises PreventRequestForgerywith origin-aware verification on top of token-based CSRF. If you have custom CSRF handling or API routes with unusual origin configurations, test them explicitly before deploying.
What’s Not Yet There
Let’s be direct about the gaps.
Streaming responses — where the model outputs tokens progressively rather than in a single payload — are not yet a first-class concern in the AI SDK’s stable release. For chat interfaces that need token streaming, you’ll still be reaching for provider-specific solutions or custom SSE handling. Watch the 13.x changelog; this is an obvious next primitive.
Multi-modal input — sending images or audio to the model rather than from it — is also not yet documented in the stable SDK surface area. It’s likely coming in a 13.x release, but don’t plan around it until it ships.
The vector search integration is Postgres-only for now. If you’re on MySQL or MariaDB, whereVectorSimilarTois not available. For those stacks, external vector stores (Pinecone, Qdrant, Weaviate) remain the path, and you’ll need your own integration layer.
Final Thoughts
Laravel 13 is not a rewrite. It does not break your application. What it does is establish a new baseline for what “standard Laravel AI work” looks like — and that baseline is considerably higher than it was under Laravel 12. The teams that adopt this now and build their AI features against SDK contracts rather than raw provider clients will have significantly less migration debt when Laravel 14 arrives and extends these primitives further.
Get on PHP 8.3, publish config/ai.php, and start moving your AI layer onto the SDK. The upgrade cost is low. The cost of not doing it compounds.
For the official release notes and full SDK documentation, see the Laravel 13 Release Notes and the Laravel AI SDK documentation directly.
Ahnii!
Mercure lets you push real-time updates to browsers using server-sent events (SSE), without WebSocket complexity. This post covers how Minoo, a community platform built on the Waaseyaa framework, uses Mercure for real-time messaging with threads, user blocking, and email notification digests.
How the messaging layers fit together
The messaging system has four layers:
When a user sends a message, the controller saves it to the database, then publishes an event through Mercure. Every browser with that thread open receives the message instantly. Users who are offline get an email digest every four hours.
The MercurePublisher
The publisher lives in the waaseyaa/mercure framework package. It takes a hub URL and JWT secret, then posts updates to the Mercure hub:
final class MercurePublisher
{
public function __construct(
private readonly string $hubUrl,
private readonly string $jwtSecret,
) {}
public function publish(string $topic, array $data): bool
{
if (!$this->isConfigured()) {
return false;
}
$ch = curl_init($this->hubUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $this->buildPostBody($topic, $data),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->generateJwt(),
'Content-Type: application/x-www-form-urlencoded',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $result !== false && $httpCode >= 200 && $httpCode < 300;
}
}
The isConfigured() check lets the publisher degrade gracefully in environments where Mercure is not running (like local development without Docker). If the hub URL or JWT secret is empty, publish() returns false without throwing.
Publishing a message from the controller
When the messaging controller saves a new message, it publishes to a topic scoped to the thread:
$this->publishMercure("/threads/{$threadId}", [
'type' => 'message',
'message' => [
'id' => (int) $message->id(),
'thread_id' => $threadId,
'sender_id' => $userId,
'body' => $body,
'created_at' => $now,
],
]);
The topic follows the pattern /threads/{id} so the JavaScript client knows what to subscribe to. The private publishMercure method delegates to the MercurePublisher with null-safe access ($this->mercurePublisher?->publish(...)).
Subscribing from the browser
The frontend uses the native EventSource API to subscribe to the Mercure hub. No library needed:
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', `/threads/${threadId}`);
const eventSource = new EventSource(url, { withCredentials: true });
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'message') {
appendMessage(data.message);
}
} catch {
// Ignore malformed events
}
};
The hubUrl (typically /.well-known/mercure) is passed from the server template. EventSource handles reconnection automatically. If the connection drops, the browser reconnects and receives any events it missed (Mercure tracks the Last-Event-ID header). The MercureConnection class in Minoo also includes a polling fallback for environments where SSE is unavailable.
Enforcing user blocks at thread creation
When a user creates a thread, the controller checks the block relationship in both directions before allowing it:
$blockStorage = $this->entityTypeManager->getStorage('user_block');
foreach ($participantIds as $participantId) {
if ($participantId === $creatorId) {
continue;
}
$blocked = $blockStorage->getQuery()
->condition('blocker_id', $participantId)
->condition('blocked_id', $creatorId)
->range(0, 1)
->execute();
if ($blocked !== []) {
return $this->json(['error' => 'Cannot message a user who has blocked you'], 403);
}
$blocking = $blockStorage->getQuery()
->condition('blocker_id', $creatorId)
->condition('blocked_id', $participantId)
->range(0, 1)
->execute();
if ($blocking !== []) {
return $this->json(['error' => 'Cannot message a user you have blocked'], 403);
}
}
The check runs against the user_block entity storage. It queries both directions: whether any participant has blocked the creator, and whether the creator has blocked any participant. Either case returns a 403. This prevents threads from being created between blocked users, rather than silently dropping messages after the fact.
Email digests for offline users
Not everyone is online when a message arrives. The MessageDigestCommand runs on a cron schedule and emails summaries of unread messages:
# Cron entry (every 4 hours)
0 */4 * * * cd /home/deploy/minoo/current && php bin/waaseyaa messaging:digest
The command queries for messages created since the last digest run where the recipient has not read them. It groups messages by thread and sends one email per recipient with all their unread threads. This avoids flooding inboxes with individual notification emails.
Thread data model
The data model uses three tables:
last_read_at timestampthread_id, sender_id, body, and created_at
The last_read_at timestamp on the participant record is how the digest command knows which messages are unread. When a user opens a thread, the frontend updates last_read_at to the current time.
Beyond the server side
The JavaScript modules that handle the UI (typing indicators, scroll-to-bottom, unread badges) and the infrastructure for deploying Mercure alongside Caddy on a VPS are separate topics. The Mercure hub also supports additional event types beyond messages: Minoo publishes typing, read, message_edited, and message_deleted events through the same /threads/{id} topic, giving the frontend a complete real-time view of thread activity.
Baamaapii
Every AI agent that browses the web faces the same question: how do you represent a web page to a language model?
The default answer, raw HTML, is expensive and slow. A typical page dumps 30,000+ tokens into your context window, most of it CSS classes and layout divs. But what are the actual alternatives? And do they work?
We ran WebTaskBench, 100 tasks across GPT-4o and Claude Sonnet 4, to find out. The results surprised us.
The Three Representations
When an agent needs to understand a web page, there are three common approaches:
1. Raw HTML
The DOM as-is. Every <div>, every class="sc-1234 flex items-center gap-2", every inline script. This is what most agents send today.
<div class="sc-1234 flex items-center gap-2 px-4 py-2">
<a href="/about" class="text-blue-500 hover:underline
font-medium tracking-tight text-sm">About</a>
<span class="text-gray-400">|</span>
<a href="/pricing" class="text-blue-500 hover:underline
font-medium tracking-tight text-sm">Pricing</a>
</div>
Pros: Complete fidelity to the DOM. No information lost.
Cons: 80-95% of tokens are noise (styling, scripts, tracking). Expensive. Slow.
2. Markdown
Strip the HTML to readable text, preserving structure through Markdown conventions. This is what tools like Jina Reader and many scraping libraries produce.
[About](/about) | [Pricing](/pricing)
Pros: Dramatically fewer tokens. Human-readable.
Cons: Loses interactive elements. No way to know what’s clickable. Navigation tasks become guesswork.
3. SOM (Semantic Object Model)
A structured JSON representation that preserves meaning and interactivity while stripping presentation noise. Each element includes its semantic role and available actions.
{
"role": "navigation",
"elements": [
{ "role": "link", "text": "About", "id": "e_a1b2c3", "attrs": {"href": "/about"}, "actions": ["click"] },
{ "role": "link", "text": "Pricing", "id": "e_d4e5f6", "attrs": {"href": "/pricing"}, "actions": ["click"] }
]
}
Pros: Minimal tokens. Preserves interactivity. Clear semantic roles.
Cons: Requires a SOM-aware fetcher (like Plasmate).
Token Cost Comparison
We measured input tokens across 50 web pages (news sites, documentation, e-commerce, government sites, social platforms). The differences are stark:
Token Cost Comparison:
Markdown wins on raw token count, it strips everything. But tokens aren’t the whole story.
Cost Per 1,000 Pages (at $3/M input tokens)
Cost Per 1,000 Pages (at $3/M input tokens):
If you’re just extracting text, Markdown is cheaper. But if your agent needs to interact with pages, click buttons, fill forms, navigate, Markdown falls apart.
The Latency Surprise
Here’s where it gets interesting. We expected Markdown to be fastest (fewest tokens = fastest inference). That’s true for GPT-4o:
GPT-4o Latency (seconds)
GPT-4o Latency (seconds):
SOM beats both. Why? Two reasons:
"role": "link", "actions": ["click"], the model doesn’t need to infer interactivity from context. Claude Sonnet 4 Latency (seconds)
Claude Sonnet 4 Latency (seconds):
Wait, Markdown is slower than HTML on Claude? Yes. And SOM is nearly 3x faster than Markdown.
Claude appears to struggle with ambiguous Markdown when the task requires understanding page structure. The model spends more time reasoning about what elements are clickable, what actions are available, and how to express those actions. With SOM, that information is explicit.
Category Breakdown
Not all tasks are equal. We tested extraction, comparison, navigation, summarization, and adversarial tasks (noisy pages with heavy chrome).
HTML/SOM Token Ratio by Category
HTML/SOM Token Ratio by Category:
For adversarial pages (cookie banners, heavy JavaScript, ad-filled layouts), HTML explodes with noise while SOM stays lean. The 6x ratio means you’re paying 6x more for HTML on the hardest pages.
Where Markdown Fails
Markdown works great for “read this article and summarize it.” It breaks down for:
When to Use What
Use Markdown when:
Use SOM when:
Use HTML when:
The honest recommendation: default to SOM unless you have a specific reason not to. It’s faster, cheaper than HTML, and handles interactive tasks that Markdown can’t.
Getting Started with Plasmate
Plasmate is the reference implementation of SOM. Three ways to use it:
1. CLI
npm install -g plasmate
plasmate fetch https://example.com
2. MCP Server (Claude Desktop / Cursor)
{
"mcpServers": {
"plasmate": {
"command": "npx",
"args": ["-y", "plasmate", "mcp"]
}
}
}
3. SOM Cache API
import requests
response = requests.get(
"https://cache.plasmate.app/v1/som",
params={"url": "https://news.ycombinator.com"},
headers={"Authorization": "Bearer YOUR_API_KEY"}
)
som = response.json()
For authenticated browsing (sites that require login), see the Authenticated Browsing Guide.
The Data
All numbers in this post come from WebTaskBench, an open benchmark of 100 web tasks across 50 real-world URLs. You can run it yourself and reproduce every number.
Further Reading
npm install -g plasmate