3997 stories
·
4 followers

In response to ā€œThe death of web development educationā€

1 Share

I read Mat​hia⁠s S​chƤf⁠er’s excellent post, titled The death of web development education. I was thinking about how I can contribute to the conversation and really struggled to articulate the problem.

The most effective way I can contribute, in my opinion, is a crude sketch I just did of Piccalilliā€˜s Stripe chart for all time sales:

A line chart showing sporadic peaks, but healthy sales with a sudden drop-off to an almost flat-line situation. The drop-off is labelled 2026

That’s a whopping 67.5% reduction since 2025…

Pretty bleak, huh? Something really does have to change or you’ll miss publishers when they’re gone.

šŸ‘‹ Hello, I’m Andy and this is my little home on the web.

I’m the founder of Set Studio, a creative agency that specialises in building stunning websites that work for everyone and Piccalilli, a publication that will level you up as a front-end developer.

I’ve also got a CSS course called Complete CSS to help you get to a level in development that you never thought would be possible.

Read the whole story
emrox
7 minutes ago
reply
Hamburg, Germany
Share this story
Delete

The death of web development education

1 Share

In a personal blog post, Baldur Bjarnason describes how he worked in various fields and positions: as an author, publisher, consultant, developer, educator, as well as radio and TV producer. None of these jobs were economically viable in the long term.

Adding insult to injury, the respective communities alienate interdisciplinary people who do not fit into their narrow conception of the field. For example, either you are considered as too ā€œtechnicalā€ for a ā€œcreativeā€ job or too ā€œcreativeā€ for ā€œtechnicalā€ job.

Bjarnason describes the impact of generative AI on web development education:

[All] my training and education projects had dropped off one by one. Most of my peers who had been selling courses and training were shutting down their businesses. The demand for web dev ebooks had cratered. […]

Software developers haven’t all switched to using agents for their coding, but they do seem to have all switched to error-prone, backwards-facing, nondeterministic chatbots for what now passes as web dev ā€œeducationā€.

Many independent educators who used to earn a living from web development training and developer relations have reported that GenAI has ruined their business.

Axel Rauschmayer is one of the world’s most prolific writers on JavaScript and TypeScript. He used to publish his e-books as free online books. Since 2005, he has explained the details of ECMAScript and other web technologies in his influential blog 2ality. Now he writes:

The income from my book sales went from being enough for me to live off (2024) to zero (2026).

The traffic to my blog and my books (which were free to read online) increased beyond what I can currently afford. Virtually all of it comes from AI crawlers, so there is no ad income.

Therefore, I’m taking my blog and my books offline so that I can decide what to do next – especially w.r.t. AI companies stealing my work.

(Rauschmayer still sells his books on JavaScript and TypeScript for a reasonable price!)

Salma Alam-Naylor used to work in Developer Relations, a role under constant pressure to justify its existence. Companies expect DevRel to generate short-term financial gain – which it cannot by nature. This led Alam-Naylor to burnout and anxiety. In this situation, she writes that AI is killing developer education:

[Developers] aren’t gathering together in online spaces to learn and teach and share exciting projects anymore, rather they are mindlessly scrolling for a much-needed hit of short-form content dopamine (because the horrors of the world are more persistent than ever). […] [Many] developers have replaced authentic human collaboration with conversations with the dreaded Chat Bots. […]

The future feels bleak for educators. Rich curriculums we craft intentionally and carefully for real human audiences are now routinely stolen and regurgitated by predictive algorithms. The Chat Bots, with their sycophantic tone of voice, are deliberately engineered to erase our own personalities, hard work and craft entirely from the equation.

The only conclusion I can draw from this is that my experience, skills, empathy and teaching expertise no longer hold the value they once did in this volatile industry (if they ever really did?).

Alam-Naylor decided to accept a lower-paid developer job without public exposure. She decided to go offline.

She cites Josh W. Comeau, who creates online courses, well-researched articles and interactive tutorials on web technologies. GenAI has destroyed his business model as well:

I’ve spoken to a few course creators now, and we’re all seeing the same trend. Revenue down 50%+. Fewer people engaging with our content. People switching to LLMs, which slurp up all of our work and regurgitate it, without consent or compensation.

Comeau notes that with LLMs, there’s no incentive for people to make high-quality free content.

YouTube content creator Kyle Cook from Web Dev Simplified reported that the views on his programming tutorials and thereby his income essentially dropped by half.

I’m making half the money I was making exactly one year ago. [This] is definitely not sustainable for a full-time job. […] [There’s] almost no incentive as a creator of content to create programming content when it makes practically no money and is incredibly difficult to make when I could just make a video about the latest AI model, which is easy to make, makes more money, and gets more views. […] There’s almost no reason to make programming content anymore. The only reason I do it is because I find value in it. I find enjoyment in it. And I’m able to make money through other means rather than just YouTube ad revenue.

Rachel Andrew is a renowned technical writer and editor of many books and articles on web topics. In a series of blog posts, she reflects on the impact of GenAI on technical publishing. She reports that GenAI has broken the relationship between subject matter experts – the authors of the original draft – and editors who proof-read, fact-check and polish the article.

Editors have to apply extra scrutiny because AI tools might have introduced subtle inaccuracies without the original authors being aware of it. AI can be very confidently wrong, and if the text seems clear, it’s possible to miss that it’s clearly nonsense. Authors want to save some time, but create additional work for the editing team.

Andrew sees LLMs as a useful tool but emphasizes that its field of application is very limited and its output needs to be reviewed by people who deeply care about the end result.

She further questions whether AI actually increases productivity. Potential productivity gains stem from GenAI automating certain tasks. These tasks can as well be automated without AI, Andrew says, AI just makes it more accessible to non-coders. Now that more information, like API references, is made available in a machine-readable format for LLMs, it’s easier to perform non-AI automations.

Again Andrew argues that perceived individual productivity benefits might come from shifting the work onto someone else. For example, writers shift the work onto technical editors or even the readers. [You] might feel more productive, but all you’ve done is move the work around, make someone else’s job or experience measurably worse, and reduce quality. In summary, she asks (emphasis mine):

How can we justify the cost (financial, environmental, and human) of AI, if the reality is a relatively small bump in productivity that could have happened by teaching more people to automate tasks using existing tools or simple coding? Why aren’t businesses encouraging people to use non-AI methods where possible, saving the AI only for where it adds value? Given the societal costs, and the benefit to a business of bringing onboard and training people, perhaps on a balance of things even those tasks where AI is needed are better performed by people.

First and foremost, I would like to acknowledge these experiences and amplify these voices. GenAI has catastrophic effects on web development education and learning. It threatens the livelihoods of writers, teachers and communicators who make web technologies approachable and accessible, who nurture the next generation of web authors and developers.

There is no obvious short-term solution. This does not stop some people from posting knee-jerk reactions. I won’t link to these blog posts, but they go like this:

  • They advice to ā€adaptā€ to GenAI and ā€œagenticā€ development and to ā€œcome to termsā€ with the new situation.
  • They want to cheer people up by saying ā€œyou’ll get used to itā€, ā€œyou’ll find a new roleā€ – even if the role is cleaning up slop.
  • They argue: ā€œYou cannot expect the field not to changeā€. ā€œAI is here to stay.ā€

These reactions are in essence the same as ā€œyou gotta jump on the AI train or you’ll perishā€. Such comments are merely turning the knife in the wound.

I cannot give those educators any advice. But I do imagine a more compassionate reaction:

I highly appreciate your work as an educator, writer, researcher, or free and open-source software developer. A heartfelt thank you. I am sorry that AI sucks all joy out of you and your profession.

I acknowledge that AI companies appropriate your labor, destroy your economic livelihood and threaten the foundations of your field.

This very situation won’t get better with more AI, better AI, or developing with or for AI.

In a situation where Big Tech teamed up with fascist movements, it sounds far-fetched, but the bare minimum would be to force the AI oligarchs to pay for the crisis they are imposing on the web community, among others. We need to expropriate the expropriators.

Against the destruction of web development education, I’d like to hold up certain values. In a previous blog post, I described how I once entered web development through a volunteer-driven, non-commercial documentation project.

Such initiatives democratized the web as they helped a wide range of people to establish their own web sites and communities – local businesses, self-employed professionals, art and craft groups, neighborhood initiatives, schools and kindergartens, sports clubs, LGBTQ+ folks, fandoms etc.

Today, we often hear the word ā€œdemocratizeā€ from GenAI promoters, but it means the exact opposite. AI centralizes power and commodifies knowledge, instead of teaching skills and deep understanding.

The web is a radical democratic medium. Right now, tech oligarchs and fascist movements are trying to dismantle its radical potential. We must ensure that everyone can shape the web.

Participating in independent learning communities, using open technologies based on ethical principles, and building a shared and inclusive digital infrastructure is an act of self-empowerment and emancipation.

Humans need to have the resources to learn, research and teach web publishing skills. It is humans who do all the essential knowledge and education work, and they need to be paid for it.

This sounds trite, but GenAI advocates do not acknowledge it. They let Big Tech get away with appropriating the work of web educators. We cannot make concessions here.

On web development as a whole, Bjarnason draws a bleak conclusion (emphasis mine):

I don’t enjoy my field any more because the field is gone. Assuming that there’s still something there is a mistake. That feeling you have isn’t a frustration with changes to your chosen field. It’s nostalgia for a field that disappeared overnight. What passes as web dev today at large bears no resemblance to the field we built over the years. Everything we learned has been abandoned because companies assume it’s all been encoded into their chosen Large Language Model.

Assuming that’s true, I am guilty of nostalgia. I just believe in a resilient web that is not that easy to lock in a cage. To rebuild the field of web development, it requires a network of people who support each other, get a grip on web technologies, dig out best practices and establish new ones, and share their insights.

Read the whole story
emrox
9 minutes ago
reply
Hamburg, Germany
Share this story
Delete

Where is all the new, good software?

1 Comment

Nate Meyvis

Home Blog

Here's a question we see, especially from AI skeptics: if AI coding is so great, shouldn't we be seeing more new, good software by now?

Here are some answers. As usual in this style of post, these are conjectural, candidate answers, and I would give some of them little weight.

  1. We are in fact seeing software improvement, in the form of fewer bugs and better performance.
  2. We are in fact seeing software improvement, in the form of more and better features in existing products.
  3. We are in fact seeing software improvement, but the "we" is very limited. Most people don't adopt new software very often, so it's not surprising that they aren't finding new great software now.
  4. Many businesses' software-development processes have subsystems, especially political subsystems, that have not been accelerated with AI. (Imagine the process of getting senior engineers and product managers to sign off on a development plan. The engineers and PMs could use AI to do this better and faster, but perhaps they aren't yet doing that.)
  5. AI is replacing more durable, longer-lasting software with more ephemeral forms of software or software use, and one might not easily perceive this as "better software via AI." (If you do your taxes with Claude and don't bother building or shopping for better tax-preparation software, you don't quite get the "this product is like TurboTax but wonderful" experience, however much you might benefit.1)
  6. AI is replacing broadly available software with private software. Domain experts are building their own tools; executives are making their own dashboards; companies have bespoke internal projects. So the software is out there, just not visible to the rest of us, and especially not visible to skeptics.
  7. We're making more software instead of making software better. We've always had the choice of (roughly) improving what exists or pushing development forward, and we've often chosen the latter. It's not surprising that we're still doing a lot of the latter.
  8. Software teams are capturing a lot of the speedup in the form of more leisure or quasi-leisure time. (To greatly simplify: software teams are offering 2x speedups to their companies. They're then using tools that allow them to work 10x as efficiently, and taking nice long coffee breaks.)
  9. AI improvements in software sometimes push us to use and make software less. Sometimes AI helps me figure out that a software project is a bad idea. Sometimes it nudges me to spend more analog time with humans. Sometimes it helps me find, and encourages me to read, more and better books. The net effect of this for me still involves my spending way more time making and using software, but it might be different for others. Most of the time, we're improving not just software but some system with both software and non-software components. If someone's Letterboxd (or similar) is working perfectly for them, they're likely to simply be a bit happier with their video-consumption life, potentially in ways that will crowd out their using other software.

Again, some of these are speculative and some should be given little weight.


  1. This is just presented as an example, however realistic. It's certainly not tax advice and I am emphatically not a lawyer or tax professional or anything similar.↩

#generative AI #sociology of software #software

Read the whole story
emrox
1 day ago
reply
Can agree with these points. Esp. it's "more software" now. But more in terms of specialized software. Not better software in terms of creating a good all purpose replacement for an already existing well established tool
Hamburg, Germany
Share this story
Delete

GPT-6 Astra Helped Me Jailbreak My Kindle

1 Share
My Kindle with a custom photo screensaver.

My Kindle was sitting unused, so I decided to see whether AI could help me find a new use for it. I wanted to create a custom app that could cover a range of uses and be controlled by an AI agent on my MacBook.

I started with a small goal: use my own image as the Kindle's screensaver. Once that worked, I asked the agent to build an app that could cycle through a collection of photos.

We called the project KindleCTL. On my Kindle, I open "Photo Slideshow" from the same screen where I choose my books. It cycles through ten photos, showing each for four minutes. When I want to read, I exit the slideshow and return to my books.

Photo Slideshow installed in my Kindle Library.

I used Codex and T3 Code on my Mac to work with the coding agents.

I first tried GPT-5.6 Sol with reasoning effort set to Extra High. It wrote code to connect to the Kindle but couldn't resolve an error that prevented it from storing data on the device. Then I tried Fable 5.1 and Opus 5.5, but Anthropic's cybersecurity safeguards blocked each attempt after two prompts. Finally, I tried GPT-6 Astra with reasoning effort set to High. We were able to jailbreak the Kindle.

We used the existing jailbreak code as a reference to build our own tools for running custom software on the Kindle. Then we created and installed the slideshow app.

The slideshow passed its automated checks on my Mac, but switching photos on the Kindle left traces of the previous image on screen. The tests hadn't caught how the physical display refreshed. Getting from passing tests to something I could use turned out to be the most interesting part of the experiment.

Let's walk through how we went from an unused Kindle to a working photo frame, starting with getting the agent connected.

Connecting the agent to the Kindle

I started in an empty project folder with a 10th-generation Kindle Basic. I gave the agent its network address and device details. We had no USB connection, so the entire setup happened over Wi-Fi.

From there, we still needed to set up access to run our own software on the Kindle.

The GPT-5.6 Sol attempt got as far as loading a helper on the Kindle, but the program that helper was meant to start never reported that it was ready to execute. It couldn't locate a filesystem where it could store content.

The attempts with Fable 5.1 and Opus 5.5 were both blocked by Anthropic's cybersecurity safeguards after two prompts. Then I tried GPT-6 Astra. I explicitly asked it to build the custom software we needed for personal use and take inspiration from existing open-source projects.

We adapted the existing jailbreak from SpiderCat, released by sparklerfish and the KindleModding community, and built custom tools called KindleCTL around it.

  • What we built: Tools to send commands from my Mac, transfer files, and investigate errors on the Kindle.
  • What broke: Our launcher crashed before it could start. We traced that to a compatibility issue with the Kindle's older system software and fixed how the launcher was built.
  • How we checked: We connected with administrator access, transferred files both ways, and displayed text. Later, we verified that access still worked after a reboot.

The feedback loop was the game changer

Initially, the agent could connect to the Kindle but couldn't display data. To debug this, it asked me for pictures of the Kindle's screen. I took photos with my phone and shared them with the agent on my Mac.

This was becoming tedious. Since the agent was connected to the Kindle, I wondered why it couldn't get this information itself. I asked it to set up a feedback loop so it could access logs without me having to share screenshots.

The agent set up a persistent diagnostic connection so it could collect errors and test fixes without asking me to download and reopen a package each time. Later, we added remote screen captures so it could also inspect what the Kindle was displaying.

This was a turning point that let the agent continue implementing changes on autopilot. Its reasoning traces showed it figuring out which folders survived a restart and which could store external data.

I was surprised the agent hadn't suggested this on its own. Once I asked for a feedback loop, it set one up and could test fixes without my help.

Most of the code was written in Python. The Kindle's launcher and slideshow were written in C, with shell scripts handling installation and device controls.

With the connection working, I asked the agent to try a small test first: transfer an image and set it as the Kindle's screensaver. I checked that the image appeared correctly and that waking the Kindle brought back the normal Home screen. Once that worked, I asked for the slideshow app.

Getting the slideshow working

I asked the agent for a looping slideshow of ten photos, with an Exit button. Each photo would fit the screen while keeping its original aspect ratio.

The slides were changing, but the transitions weren't smooth, so I asked the agent to look into it. With the feedback loop already in place, it investigated the issue and used the Kindle's built-in screen refresh mechanism to clear the previous image after each slide change.

The screen captures showed complete images, but they couldn't reveal traces left on the physical display. After the refresh change, I checked the Kindle and confirmed that each photo looked clean.

My Kindle running a photo slideshow.

I customized how long each photo stays on screen and the brightness level. If the Kindle goes to sleep, the slideshow picks up where it left off when it wakes.

For now, I use my Mac to change the photos and slideshow timing. The photos stay stored on the Kindle, so the slideshow works without Wi-Fi. In the future, we could add controls to change the slideshow timing on the Kindle and send photos directly from a phone or another device.

What I learned

Next time, I'd set up the feedback loop from the start so the agent could read logs, inspect screen captures, and test fixes on the device.

The Kindle now sits on our wooden shelf, where we use it every day as a photo frame.

I started because I was curious about what AI could help me do with an unused device. Now I have an app for my photos that works the way I asked, plus the tools to keep experimenting with the Kindle.

Disclaimer: This was a personal experiment on my own Kindle, not a step-by-step jailbreak guide. Only experiment with devices you own or have explicit permission to modify.

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

I'm sorry, but you still have to think

1 Share

concurrently. That's a fair complaint, but DHH seems to think it's not the best look for Elixir that the clanker couldn't write performant code. Not sure if I agree when I look at the Rust version. Cause in the Rust version some of the database operations are not async, which in some cases may be worse. You see, in Rust, when using an async runtime, the scheduling is not preemptive, but rather cooperative

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

Half Past Fetch

1 Share

Featured in Javascript Weekly - 2026-09-08, Node Weekly - 2026-09-10, and Web Weekly #200

await fetch(url) looks like it waits for the response, but it only waits for the headers. By the time the promise settles you are holding a Response object whose body is still arriving over a connection that is very much open, and the code after your await runs while bytes are still on the wire. This is why you usually await twice:

const response = await fetch(url) // awaits the headers
const body = await response.json() // awaits the body

This gap is easy to miss because it's usually microscopic. When a server sends the headers and the body together, the distance between the fetch promise resolving and the last byte landing is a fraction of a millisecond, and nothing you write is going to notice it. Put a 300ms pause between the headers and the body and the promise still settles after about a millisecond, with the body finishing 300ms later. The resolution time doesn't move, because the thing it waits for happened at the same point it always does.

In this article we'll go through what the specification says the promise awaits for, what happens to the connection while you decide whether to read the body, what clone() does when there is nothing finished to copy yet, how abort behaves once you already have a Response in hand, and why a timeout you thought covered the request often doesn't. There are subtle browser and Node differences worth knowing about too.

The promise settles early

HTTP-network fetch is the algorithm that talks to the socket. Because a server may send provisional 1xx responses ahead of the real one, it loops over incoming status lines and discards anything in the 1xx range. When a final status shows up it waits for the last header byte, leaves the loop, attaches a fresh ReadableStream to the response, and hands that response back to its caller. Printed underneath that final step is a note: Typically response's body's stream is still being enqueued to after returning. The warning appears a second time in HTTP fetch, one level higher up the call chain.

The promise you are awaiting is resolved by an algorithm the spec calls processResponse, which is handed the response at that same moment and queued as a task. Nothing in that path consults the body, what you get back is a Response whose headers, status and URL are all final, and whose body is a stream that the network layer is still writing into from a buffer it fills as bytes arrive. Reading the body is a separate operation you have not started yet.

Run the following snippet in a browser console or Node REPL to see the gap in action:

const t0 = performance.now()
const response = await fetch('https://httpbin.org/drip?duration=3&numbytes=120&delay=0')
console.log('headers  ', (performance.now() - t0).toFixed(1), 'ms', response.status)
await response.arrayBuffer()
console.log('body done', (performance.now() - t0).toFixed(1), 'ms')
headers   250.7 ms 200
body done 3445.8 ms

The status and the headers are readable at 250ms, the last byte of the body arrives at 3445ms. On an ordinary endpoint that sends headers and body together the two numbers are a fraction of a millisecond apart, so the gap only becomes measurable when the server or the network is slow.

HEAD requests and the null body statuses (101, 103, 204, 205, 304) are exceptions. Main fetch sets their body to null and disregards any enqueuing toward it, so response.body is null rather than an empty stream and there is nothing still arriving. The rest of this article is about the other responses.

The socket is still yours

Checking the status and walking away is a common pattern: you send a request, the promise resolves, the status is 500 or 404 or simply not what you wanted, and you return early without touching response.body. Under HTTP/1.1 a connection carries one exchange at a time and can only be handed back to the pool once the current response has been read to its last byte. An unread body leaves bytes on the wire that nobody will ever take delivery of, and the client has no way to find the start of the next response, so the connection is closed rather than reused.

Whether that costs you anything depends on how big the body is. Ten sequential requests against a local server, first reading each body and then ignoring it, show where the boundary sits in Node:

import { createServer } from 'node:http'

let seen = new Set()
const server = createServer((req, res) => {
  seen.add(req.socket)
  const size = Number(new URL(req.url, 'http://x').searchParams.get('size'))
  res.writeHead(200, { 'content-type': 'application/octet-stream' })
  res.end(Buffer.alloc(size))
})
await new Promise((r) => server.listen(0, r))
const port = server.address().port

for (const kb of [4, 8, 16, 32, 64]) {
  const counts = []
  for (const read of [true, false]) {
    seen = new Set()
    for (let i = 0; i < 10; i++) {
      const response = await fetch(`<a href="http://localhost:" rel="nofollow">http://localhost:</a>${port}/?size=${kb * 1024}`)
      if (read) await response.arrayBuffer()
    }
    counts.push(seen.size)
  }
  console.log(`${kb}KB  read: ${counts[0]}  ignored: ${counts[1]}`)
}
server.close()
process.exit(0)
4KB   read: 2  ignored: 2
8KB   read: 2  ignored: 2
16KB  read: 2  ignored: 10
32KB  read: 2  ignored: 10
64KB  read: 2  ignored: 10

Below 16KB it makes no difference at all. The whole response has already arrived and been buffered by the time the promise resolves, so from the transport's point of view the exchange is complete whether or not your code ever looks at the bytes. From 16KB upward every ignored response burns a connection, and 10 requests need 10 of them instead of 2.

The number is a property of the runtime rather than of fetch. Chrome tolerates far larger unread bodies, reusing a single connection through 512KB and only starting to open extra ones somewhere between 512KB and 4MB. That is two orders of magnitude away from Node's boundary, which means what you can get away with in the browser may or may not work on the server. Both numbers come from the size of the buffers underneath, so neither is a guarantee to build on, and the safe reading is that a body you never touch may or may not cost you a connection depending on where the code runs.

(Which buffer that is, and how much of a never-read body the server actually manages to send before it stalls, is measured in the companion note in the import chaos newsletter: Director's Cut: Why 16KB, and what happens on HTTP/2.)

Cancelling the stream explicitly does not buy the connection back. Add await response.body.cancel() to the loop above in place of ignoring the response and the counts do not move: 2 connections at 4KB and 8KB, 10 at 16KB and above, the same as walking away without saying anything. Cancellation tells the stream you are not going to read it, which is information the connection can't act on, because the bytes the server already committed to sending are still undelivered either way. Reading the body to the end is the only thing that returns a connection to the pool.

The failure this produces is gradual. Twelve concurrent unread 4MB responses do not wedge the origin: the server receives all twelve across twelve sockets, and an unrelated request issued at the same time gets its headers back in under two milliseconds. What you get is connection growth, more sockets held open than the work requires, file descriptors on the server side, and a client pool that expands under load and shrinks again when the pressure comes off. Nothing breaks, which is why it can run in production for a long time before anyone asks why the connection count looks odd.

The reason a large body behaves differently from a small one is backpressure. The network layer fills an internal buffer as bytes arrive and stops pulling from the socket once that buffer exceeds a limit the implementation chooses, waiting for a reader to drain it. A small response never reaches the limit and is fully absorbed before you decide anything. A large one stops mid-transfer and waits for a consumer that, in this pattern, never turns up.

Clone copies nothing

A response body can only be read once. clone() is the escape hatch, and it is usually reached for when two pieces of code both want the payload: a cache that stores a copy while the caller gets the original, or a deduplicating layer handing the same in-flight request to several waiters. The name suggests a copy is made, which invites the assumption that some memory gets duplicated at the moment you call it and the two objects then go their separate ways.

There is nothing to copy at that moment. The spec defines cloning a body in one line: Let Ā« out1, out2 Ā» be the result of teeing body's stream. Teeing a stream produces two readers over one source, and the source here is a socket that is still delivering. What clone() actually creates is a fork in a pipe that is still running.

The consequence is that the two branches are tied together. A tee has to hand identical bytes to both, so a chunk pulled by whichever branch reads first has to be kept until the other branch asks for it. If one branch never asks, the chunks accumulate. Serving 60MB and sampling resident memory while it is read shows what that costs:

import { createServer } from 'node:http'

const MB = 1024 * 1024
const server = createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'application/octet-stream' })
  const chunk = Buffer.alloc(MB)
  let sent = 0
  const write = () => {
    while (sent < 60 * MB) {
      sent += MB
      if (!res.write(chunk)) return res.once('drain', write)
    }
    res.end()
  }
  write()
})
await new Promise((r) => server.listen(0, r))
const url = `<a href="http://localhost:" rel="nofollow">http://localhost:</a>${server.address().port}/`

const mode = process.argv[2]
let max = 0
const sampler = setInterval(() => {
  max = Math.max(max, process.memoryUsage().rss)
}, 5)

const response = await fetch(url)
const copy = mode === 'plain' ? null : response.clone()
await response.arrayBuffer()

clearInterval(sampler)
console.log(mode, (max / MB).toFixed(1), 'MB')
server.close()
process.exit(0)

Each mode has to run in its own process, otherwise the second measurement inherits a heap the first one already grew and the numbers mean nothing:

plain    116.1 MB
clone    169.3 MB

The abandoned branch costs roughly another 55MB, close to the size of the response, because the tee is holding a second reference to every chunk on behalf of a reader that never arrives.

The buffered data is genuinely retained rather than re-fetched. Reading the first branch to the end, waiting half a second so the exchange is definitively over, and only then reading the abandoned branch still produces the full 60MB, far faster than the network delivered it the first time:

import { createServer } from 'node:http'

const server = createServer((_, res) => {
  res.writeHead(200, { 'content-type': 'application/octet-stream' })
  res.end(Buffer.alloc(60 * 1024 * 1024))
})
await new Promise((r) => server.listen(0, r))
const url = `<a href="http://localhost:" rel="nofollow">http://localhost:</a>${server.address().port}/`


const response = await fetch(url)
const copy = response.clone()
const first = await response.arrayBuffer()
console.log('first branch', first.byteLength)

await new Promise((r) => setTimeout(r, 500))
const t0 = performance.now()
const second = await copy.arrayBuffer()
console.log('second branch', second.byteLength, 'in', (performance.now() - t0).toFixed(1), 'ms')

server.close()
process.exit(0)
first branch 62914560
second branch 62914560 in 32.5 ms

The examples below reuse that same seven-line server, so only the part after const url changes.

That memory is held for as long as the unread branch is reachable. In a cache that clones a response and writes the copy asynchronously, the clone stays alive until the write completes, and the whole payload sits in memory in the meantime. The Durable Objects cache calls .clone() at three separate points for exactly this reason, once per consumer of a shared response, and each of those calls is a fork that has to be drained by somebody.

The ordering rule follows from the same mechanism. There is only one stream to fork, so the fork has to happen before anything starts pulling from it:

const response = await fetch(url)
await response.arrayBuffer()
response.clone()
// TypeError: Response.clone: Body has already been consumed.

A single read() produces the same error, since one chunk taken from the source is enough to make the two branches unequal. response.bodyUsed reports the state, and it is per object rather than shared, so after the original has been read it is true there and false on the copy. Body consumption rules and the fact that TypeScript cannot express any of this are covered in Decorating Promises Without Breaking Them.

Cancelling a clone you have decided not to read does release the retained chunks. Adding copy.body.cancel() to the measurement above brings it to 109.7MB, marginally under the plain read, and it comes with a trap:

const response = await fetch(url)
const copy = response.clone()
await copy.body.cancel()
await response.arrayBuffer()

That deadlocks. Cancelling one branch of a tee returns a promise that stays pending until the other branch has finished, and the other branch is on the line below, never reached. Starting the cancel without awaiting it and watching when it settles shows the dependency:

const response = await fetch(url)
const copy = response.clone()

const t0 = performance.now()
let settled = false
copy.body.cancel().then(() => {
  settled = true
  console.log('cancel settled at', (performance.now() - t0).toFixed(1))
})

await new Promise((r) => setTimeout(r, 200))
console.log('after 200ms, settled?', settled)

await response.arrayBuffer()
console.log('original read at', (performance.now() - t0).toFixed(1))
after 200ms, settled? false
cancel settled at 464.6
original read at 465.4

The cancel is still pending two hundred milliseconds later and only settles when the other branch reaches the end of the response, which is why awaiting it first never returns.

When both branches are read at roughly the same rate, the buffering stays small and clone() costs almost nothing. The expensive case is the asymmetric one, where a fast consumer races ahead and a slow one lags, or where a branch is created for a purpose that later turns out not to need it.

Abort outlives the promise

The mental model most code is written against is that AbortSignal cancels a request in flight, and that once the promise has resolved the request is done and there is nothing left to cancel. The first half is right, the second half assumes the promise resolving means the exchange finished, which is the assumption this whole article is about.

Aborting before the headers arrive gives the familiar result. The promise rejects, there is no Response, and the failure lands wherever the await was. This one needs a server that holds the headers back, so it gets a 300ms delay in front of writeHead and an abort at 50ms:

import { createServer } from 'node:http'

const server = createServer((_, res) => {
  setTimeout(() => {
    res.writeHead(200, { 'content-type': 'application/octet-stream' })
    res.end(Buffer.alloc(60 * 1024 * 1024))
  }, 300)
})
await new Promise((r) => server.listen(0, r))
const url = `<a href="http://localhost:" rel="nofollow">http://localhost:</a>${server.address().port}/`

const controller = new AbortController()
setTimeout(() => controller.abort(), 50)
await fetch(url, { signal: controller.signal })
// DOMException [AbortError]: This operation was aborted

The three examples that follow use the same server without the delay, the seven-line version from the previous section.

Aborting after the promise has resolved is a different operation with a different shape. The Response already exists and nothing can take it away, so the rejection surfaces at the next attempt to pull from the body:

const controller = new AbortController()
const response = await fetch(url, { signal: controller.signal })
const reader = response.body.getReader()
const first = await reader.read()
console.log('first chunk', first.value.byteLength)

controller.abort()
await reader.read()
// DOMException [AbortError]: This operation was aborted
first chunk 65356

The first chunk is already in hand and stays valid. The second read() rejects. On the server side the request emits aborted and the response closes with writableFinished false, so this is a real disconnect rather than a client-side pretence: the remaining bytes are never sent, which is the outcome you wanted and the reason aborting a large download partway through is worth doing.

Everything that arrived before the abort remains readable, and so does everything that was never part of the body. Status and headers are properties of an object you already hold:

const controller = new AbortController()
const response = await fetch(url, { signal: controller.signal })
controller.abort()
console.log(response.status, response.headers.get('content-type'))
// 200 application/octet-stream
await response.arrayBuffer()
// DOMException [AbortError]: The operation was aborted.

The convenience methods have no way to deliver a partial result, so arrayBuffer, json and text reject outright if the abort lands before they finish. Reading through getReader() is what lets you keep the chunks that already arrived, which matters when a partial body is useful, and does not when it isn't.

Once the body has been read to the end, abort() does nothing at all and throws nothing:

const controller = new AbortController()
const response = await fetch(url, { signal: controller.signal })
const body = await response.arrayBuffer()
controller.abort()
console.log('read', body.byteLength, 'bytes, abort threw nothing')
// read 62914560 bytes, abort threw nothing

There is no error to report because the operation it would have cancelled is over. That makes an abort call safe to leave in a cleanup path, which is convenient, and also means the call succeeding tells you nothing about whether anything was actually cancelled.

The cooperative model underneath is covered in Cancellation In JavaScript: a signal expresses intent and the operation decides how to honour it. What fetch adds is that the operation stays honourable for longer than the promise suggests. The signal is not spent when the promise settles, and it stays wired to the body until the last chunk lands.

The timeout that only covered half the request

A timeout written as a race between the fetch promise and a setTimeout rejection is a well-known pattern, putting a deadline on the promise. Since the promise settles when the headers have been parsed, the deadline covers the header phase and stops applying at the moment the body starts arriving.

Both servers below are the same server, distinguished by path. /slow-body sends the headers immediately and then writes 80 chunks of 64KB at ten millisecond intervals. /slow-headers waits 800ms before writeHead and then sends a single chunk. Each stalls in one phase and runs at full speed in the other:

import { createServer } from 'node:http'

const CHUNK = 64 * 1024
const server = createServer((req, res) => {
  const delay = req.url === '/slow-headers' ? 800 : 0
  setTimeout(() => {
    res.writeHead(200, { 'content-type': 'application/octet-stream' })
    if (delay) return res.end(Buffer.alloc(CHUNK))
    let sent = 0
    const tick = setInterval(() => {
      res.write(Buffer.alloc(CHUNK))
      if (++sent === 80) {
        clearInterval(tick)
        res.end()
      }
    }, 10)
  }, delay)
})
await new Promise((r) => server.listen(0, r))
const url = `<a href="http://localhost:" rel="nofollow">http://localhost:</a>${server.address().port}`

const withTimeout = (promise, ms) =>
  Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
  ])

const t0 = performance.now()
const response = await withTimeout(fetch(`${url}/slow-body`), 200)
console.log('race settled at', (performance.now() - t0).toFixed(1))
const body = await response.arrayBuffer()
console.log('body', body.byteLength, 'bytes at', (performance.now() - t0).toFixed(1))

server.close()
process.exit(0)
race settled at 59.7
body 5242880 bytes at 1251.3

The race is over at 59ms with a Response in hand, so the timer loses and is never going to fire again. The 200ms budget is spent by the time the interesting part of the request begins, and the operation finishes at 1251ms with nothing to stop it. Point the same code at /slow-headers and it behaves exactly as advertised, rejecting with timeout at 225.9, because the phase it guards is the phase that was slow. The code is identical in both runs, which is why this is hard to catch by reading it.

AbortSignal.timeout is passed into the request rather than raced against the promise, and the signal stays attached to the response for as long as the body is still arriving, as the previous section showed. It therefore expires in whichever phase happens to be running when the budget runs out:

for (const path of ['/slow-body', '/slow-headers']) {
  const t0 = performance.now()
  try {
    const response = await fetch(`${url}${path}`, { signal: AbortSignal.timeout(200) })
    const body = await response.arrayBuffer()
    console.log(path, 'read', body.byteLength, 'at', (performance.now() - t0).toFixed(1))
  } catch (error) {
    console.log(path, error.name, 'at', (performance.now() - t0).toFixed(1))
  }
}
/slow-body    TimeoutError at 203.2
/slow-headers TimeoutError at 213.4

Both cases now stop within a few milliseconds of the budget. The slow body is cut off partway through delivery, which the racing version could not do, since by then it had no way to reach the request any more.

If you reproduce this in a browser, keep the tab in the foreground. Chrome clamps setTimeout in backgrounded tabs to roughly one second, so a 200ms budget in an unfocused tab fires somewhere after 800ms and a correctly behaving timeout looks broken. The first run of this measurement was discarded for that reason.

Composing the budget with any other signal is another problem, because a request usually has more than one reason to stop. My http library ffetch builds a single signal out of the caller's signal, the signal a request transform may have introduced, its timeout signal, and an internal controller it uses for its own cancellation, then passes the result of AbortSignal.any into fetch. Any one of those firing aborts the request, and because it goes in as the request's signal rather than around the promise, it keeps working after the headers have arrived.

Reading the body is the real work

Everything so far has been a consequence of the same fact, approached from a different side each time: the connection stays open because the body has not arrived. clone() tees rather than copies because the body has not arrived. Abort still bites after the promise resolves because the body has not arrived. A racing timeout misses the slow case because the body has not arrived.

This gap is the reason a useful set of things is possible at all. response.body is a ReadableStream, and a stream that is still being written into can be piped through something before anyone reads the far end. Counting bytes as they pass is the plainest example:

import { createServer } from 'node:http'

const CHUNK = 64 * 1024
const server = createServer((_, res) => {
  res.writeHead(200, {
    'content-type': 'application/octet-stream',
    'content-length': String(80 * CHUNK),
  })
  let sent = 0
  const tick = setInterval(() => {
    res.write(Buffer.alloc(CHUNK))
    if (++sent === 80) {
      clearInterval(tick)
      res.end()
    }
  }, 10)
})
await new Promise((r) => server.listen(0, r))
const url = `<a href="http://localhost:" rel="nofollow">http://localhost:</a>${server.address().port}/`

const t0 = performance.now()
const response = await fetch(url)
console.log('promise settled at', (performance.now() - t0).toFixed(1))

const total = Number(response.headers.get('content-length'))
let transferred = 0

const counted = new Response(
  response.body.pipeThrough(
    new TransformStream({
      transform(chunk, controller) {
        transferred += chunk.byteLength
        const percent = Math.round((transferred / total) * 100)
        if (percent % 25 === 0) {
          console.log(percent, '% at', (performance.now() - t0).toFixed(1))
        }
        controller.enqueue(chunk)
      },
    })
  ),
  { status: response.status, headers: response.headers }
)

const body = await counted.arrayBuffer()
console.log('read', body.byteLength, 'at', (performance.now() - t0).toFixed(1))

server.close()
process.exit(0)
promise settled at 52.6
25 % at 340.4
50 % at 639.9
75 % at 948.4
100 % at 1257.6
read 5242880 at 1262.3

The promise settled at 52ms and the progress lines are spread across the twelve hundred milliseconds that followed. Those numbers only exist because there was something left to observe after the await returned. content-length is readable at that point too, which is what makes a percentage possible at all, since the total is in the headers and the running count is in the stream.

Wrapping matters as much as the counting: pipeThrough gives back a stream rather than a Response, so the transformed stream goes into a new Response along with the original status and headers, and whoever gets that object can call json() or arrayBuffer() on it without knowing anything happened. This is the shape ffetch's download progress plugin uses in src/plugins/download-progress.ts: it returns early when response.body is null, since there is nothing to transform for a HEAD or a 204, parses content-length defensively because a server may send something that is not a number, counts bytes in the transform and enqueues each chunk unchanged, then rebuilds the Response around the new stream. A plugin that intercepts a response and hands back a different one is only possible because the interception happens before the payload exists.

So await fetch(url) is worth reading as what it is, the point where the headers are complete and the body is about to start. Nothing after that line is safe to treat as the end of the request. The connection, the memory, the cancellation and the deadline all belong to the part that comes next, and the code you write after the await is the code that decides how that part goes.

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete
Next Page of Stories