Whether the same page with the same content responds in 20 milliseconds or takes three seconds often depends on whether it is already cached or EmDash has to generate it first.

That gap has bothered me since my first EmDash website. I built a cache-warming service, fixed bugs, reduced database queries and spent days measuring. Things improved, but the first visitor after a quiet spell could still be unlucky and wait one, two or even three seconds.

Since November 8, three of my websites have been running differently: EmDash and its database now live together inside a Cloudflare Durable Object. Uncached pages arrive in a global median of 200 to 450 milliseconds rather than 700 to 2,800. I did not change a single line of EmDash itself to do it.

Website · uncached HTML requests, median

Before: D1

After: Durable Object

scllauerz.ch, worldwide

2,837 ms

291 ms

scllauerz.ch, from Singapore

3,264 ms

256 ms

kevinkyburz.ch, worldwide, already using Placement and KV before the move

726 ms

204 ms

linguadash.dev, worldwide

1,184 ms

450 ms

The biggest improvement is on scllauerz.ch: instead of almost three seconds, the global median wait for a response is now around 0.3 seconds. That is almost ten times faster, without needing a finished page in the cache. kevinkyburz.ch and linguadash.dev improved too, by factors of 3.6 and 2.6 respectively.

For this comparison, I filtered Cloudflare Analytics to successful HTML requests where the cache could not serve a finished page. That includes both people and bots. The figures compare the seven days before the move with the days recorded so far afterwards. Each value is the median: half the requests were faster, the other half slower. How well this holds up over several weeks remains to be seen.

The main change was bringing the application and its database closer together, instead of making them wait on each other for every page.

I almost moved to a VPS

The cache stores finished pages that Cloudflare can serve directly without involving EmDash or the database. If a page is missing, EmDash has to generate it first. That happens after a deployment or publishing content, on rarely visited pages, or when visitors arrive from a region where the page is not cached yet.

With warmup.rocks, I quickly built a service that requests pages regularly so real visitors are less likely to hit an empty cache. In EmDash, I first fixed bugs that caused D1 queries to hang, then reduced the number of database queries: a menu and its items now need just one query, as do widgets. Version 1.5 should reduce the queries for bylines too.

In October, I also spent ten days measuring when the cache was empty. Visitors still had to wait despite these improvements, so I spent the past two weeks investigating the cause more closely. I tried and discarded several approaches, and at one point I was close to moving my sites to a VPS: a conventional server with Cloudflare caching in front of it.

I made progress when I took apart a single uncached request. The first prototype was ready on the evening of November 7. The next day, I moved kevinkyburz.ch first, then linguadash.dev and, in the afternoon, scllauerz.ch. The pages were fast, but I still had to find out whether the admin and heavier traffic would work with this setup too.

Why EmDash spends more time waiting than computing

In my measurements, the actual computation for a page took 64 to 159 milliseconds. That did not explain a two-second response time. The investigation revealed two main places where time was being lost.

Illustration: The orange EmDash character stands at a bus stop, checking its watch with a half-finished web page under its arm. A long railway winds towards a distant database on the horizon, with a small data bus travelling along it.
Without a cached page, EmDash waits for the bus to return from the database for every query.

Every database query takes a trip

D1 is Cloudflare's managed SQL database and runs in a separate process from EmDash. Every query therefore has to travel over the network to the database, and the response has to come back.

One such query took about 17 milliseconds in my test. That is not much on its own, but a page needs 14 to 25 queries for settings, menus, categories, content, widgets and author details. Many run sequentially, as one component fetches its data and then the next does the same.

With this constant back-and-forth between the application and the database, small waits add up to hundreds of milliseconds, even though the database itself does very little computation. The network trips take time even when EmDash is already running and no longer needs a cold start.

The Worker has to wake up first

On Cloudflare, EmDash runs in a Worker, an execution environment that Cloudflare starts where requests arrive. Once it is no longer needed, it can disappear again, forcing EmDash to start over on the next request. In my tests, sometimes just 30 seconds without a visit was enough.

A freshly started EmDash also needs more database queries: according to the query-count snapshot in the EmDash repository, the home page needs 20 rather than 8, and a post needs 26 rather than 14. The extra queries check migrations, load settings and the schema, and recover unfinished background jobs, among other things. Before the visitor gets their page, the application has to get ready to work again.

A technical look: where cold-start time goes

During the investigation, I observed or estimated these timings:

Step

Time

Make the bundle available at the location and start execution

Estimated 600 to 1,000 ms

Load EmDash modules

About 250 ms

First query to D1

170 to 330 ms

Render the page, including further queries

150 to 200 ms

These are pointers from the investigation, not a complete breakdown of every request. Overall, the cold starts I measured took 1.1 to 2.4 seconds.

The application bundle is a significant part of this: in my setup, the EmDash bundle was around 19 MB, or about 5 MB compressed. It needs to be available at every location where EmDash runs.

The dashboard's ‘Worker Startup Time’, by contrast, showed only about 20 milliseconds. It does not capture the full wait on the first page request.

A change has been merged for EmDash 1.5 that loads less code on the first request. However, the measurements in the pull request do not yet show a measurable improvement on the first request to a new Worker instance on real Workers. I will need to check the release for a reliable size comparison; the figures published so far apparently use different measurement methods.

Either way, this change does not affect the network trips to D1.

Why the cache is not enough

For a long time, I assumed that keeping as many pages as possible in the cache would be enough to address the problem. The Cloudflare figures seemed to confirm that: only 0.8 percent of HTML requests on scllauerz.ch were cache misses. But that figure also includes bots and regular requests from warmup.rocks, which produce lots of cache hits on the main pages.

To find out how often real people have to wait, I use CoreDash to measure directly in the browser.

Over a 31-day period spent almost entirely on D1, CoreDash recorded 979 page views on scllauerz.ch. I measured Time to First Byte (TTFB), the time until the first byte of the page reaches the browser:

Time to First Byte

Share of page views

Under 250 ms

62%

At least 875 ms

26%

At least 1,875 ms

14%

The two slower groups overlap: requests above 1,875 milliseconds are included in those above 875 milliseconds.

So for one in four measured page views, almost a second passed before the first byte arrived; for one in seven, it was almost two seconds. A high browser TTFB alone does not prove a cache miss, because it also includes DNS resolution and connection setup. Still, the slow requests clustered noticeably between one and two and a half seconds, exactly the range I measured with a cold Worker and D1.

Even among visitors from Switzerland, around 25 percent of requests took more than 875 milliseconds. Rarely visited pages, URLs with query strings, evicted cache entries and a cache cleared after publishing are enough to explain this. A warmer helps, but cannot keep every page ready at every location.

Illustration: A friendly eight-legged crawler robot with a headlamp, stopwatch and clipboard stands in front of a frozen, rarely visited web page. The orange EmDash character runs towards it with an armful of data packets, while popular pages glow warmly in the background.
Crawlers often request exactly the pages nobody has kept warm.

Google and AI crawlers hit exactly these pages

Search engines and AI crawlers also visit pages that nobody has read for days. They therefore often hit pages that are no longer in the cache.

The switch from WordPress to EmDash is clearly visible in Search Console for kevinkyburz.ch. On October 1, the average response time rose from 247 to 646 milliseconds.

Over the longer periods, the figures looked like this:

Period

Platform

Average response time

Aug 12 to Sep 30, 2026

WordPress

212 ms

Oct 1 to Nov 7, 2026

EmDash on D1

532 ms

The average response size remained similar at around 10 KB, so larger pages do not explain the increase. On the slowest D1 day, November 2, the average response time was 1,031 milliseconds.

With EmDash on D1, Google was therefore waiting more than twice as long for a response as it had with WordPress. Because Google averages across all requests, including images and other files, the individual values are only indicative. But the direction is clear. I will show what changed after the move further down.

Four attempts that did not go far enough

Before building my own architecture, I checked whether the existing options would be enough.

Attempt

What it improved — and what remained

Disable runtime migration checks

Using migrations: { runtime: "manual" } saved about 85 milliseconds. The slow first D1 query remained; it simply happened later.

Smart Placement and KV as an object cache

Smart Placement moves the Worker closer to the database, while KV stores ready-made query results. A running Worker could then serve pages without a database query. Even so, 11 of 21 test requests were cold and took 0.86 to 1.72 seconds, sometimes one immediately after another.

Use the official durableObjects() adapter

With the adapter from @emdash-cms/cloudflare, the database moved into the Durable Object, but EmDash stayed in the Worker. Both the network trips for each query and the Worker cold starts remained.

Use other Cloudflare products

Containers would have introduced cold starts lasting seconds and costs while idle. Hyperdrive was not a suitable replacement for this SQLite database. KV could itself be cold on the first access.

These approaches reduced individual waits, but none eliminated both the network trips and the Worker cold starts.

The solution: EmDash moves in with the database

Previously, EmDash ran in the Worker and the data lived in D1. Every query therefore had to travel between the two. A Durable Object brings them together: it is a kind of small server for a website, running in one location with its own SQLite database. Code and data live on the same machine.

Illustration: The orange EmDash character leans out of a window in a shared house with a glowing crystal roof, handing a data packet to a friendly database character in the next window. Moving boxes sit outside. Further back are the abandoned bus stop and the long railway to the old database.
Instead of sending requests across town, EmDash now simply hands them through the next window.

So I moved the database out of D1 into the Durable Object and put EmDash in there too. A query no longer crosses the network; it stays in the same process.

A small Worker sits in front, checking the cache and serving finished pages directly. If there is no hit, it forwards the request to the Durable Object.

That changes three things:

  • Database queries stay in the same process. Network trips become local calls measured in microseconds. Only the request from the front Worker to the Durable Object still crosses the network. That trip took about 8 milliseconds in my test.
  • EmDash no longer starts separately for every visitor location. All uncached requests for a website reach the same instance. It can still restart, but cold starts are no longer spread across many locations.
  • Only the small front Worker starts at visitor locations. In the prototype, it was ready in about 25 milliseconds. It does not need the large EmDash bundle.
text
Visitor
   │
   ▼
Small front Worker
   ├─ Page in cache? → serve directly
   │
   └─ Cache miss
         │
         ▼
      Website's Durable Object
         ├─ EmDash generates the page
         └─ SQLite supplies the data locally

In this first attempt, the changes were around EmDash, not inside it. I used two extension points EmDash already offers: a custom database entrypoint and a custom cache.provider. If this setup is to become part of EmDash itself, EmDash will need to change too. More on that at the end.

The database dialect sends SQL directly to ctx.storage.sql. The cache provider makes sure affected pages are removed from the cache when content is published.

In the lab: up to ten times faster after a pause

Before switching the first website, I tested the setup extensively in the lab. I ran three versions of a copy of the EmDash demo with the same data side by side: with D1, with D1 plus Smart Placement and KV, and inside a Durable Object. I sent requests from Zurich using curl and a fresh query string each time to bypass the cache.

I was most interested in the pauses. A Worker only stays active for a short time after a request. If nobody visits for a while, it goes to sleep, and the next visitor has to wait for it to start again and reach D1. This happens constantly on a small website: visits are often minutes apart.

A Durable Object behaves differently. It runs in one location, all requests for a website arrive there, and it stays awake as long as it has work to do. Its data is already there. In my tests, I helped it along with a timer every 30 seconds and an alarm every 20 seconds so it would not fall asleep between visitors.

Scenario

D1

D1 with Smart Placement and KV

Durable Object

Worker already running

0.29–0.39 s

0.17–0.39 s

0.08–0.23 s

First request at a new location

1.1–2.4 s

0.86–1.72 s

0.10–0.28 s

After 30 to 180 seconds without visitors

1.1–2.4 s

Cold starts observed from 30 s

0.12–0.16 s

After 5 / 10 minutes without visitors

1.19 / 1.28 s

—

0.21 / 0.13 s

After pauses and at new locations, the Durable Object took 0.1 to 0.3 seconds rather than 1.1 to 2.4 seconds; after ten minutes without visitors, it took 0.13 rather than 1.28 seconds. Smart Placement and KV only helped while the Worker was running. After a pause, it was cold again.

Finally, I compared the live scllauerz.ch website with a test instance in the Durable Object. Five pages, requested three times each, took 1.1 to 3.0 seconds on the live site and 0.15 to 0.35 seconds on the test instance. These are individual test runs from Zurich, not long-term measurements.

What visitors actually notice

The Cloudflare figures at the beginning already show a clear difference. The more interesting question is whether that difference reaches the browser too.

For scllauerz.ch, CoreDash reports:

Metric

Previous 31 days, mostly D1

First 24 hours after the switch

24 hours ending Nov 10, 8 p.m. CET

Time to First Byte, p75

1,025 ms

196 ms

296 ms

Largest Contentful Paint, LCP p75

1,412 ms

580 ms

840 ms

Share of TTFB measurements rated poor

15%

7%

0%

The 75th percentile, or p75, means three quarters of measured requests were at least as fast as this value. LCP describes when the largest visible piece of page content has loaded.

After the switch, all the figures are well below those recorded with D1. In the last 24 hours, two thirds of requests reached the browser in less than 250 milliseconds, and none took longer than 2.5 seconds. Slow requests have not disappeared entirely, though: on the day after the move, three still exceeded that threshold.

I do not yet have an equivalent browser comparison for kevinkyburz.ch. Before the move, I have a worldwide p75 of 1,247 milliseconds; afterwards, a median of 124 milliseconds from Switzerland. Different regions and different metrics do not make a credible speed-up factor.

And what does Google see?

After the switch, around 90 percent of uncached requests came from bots, including warmup.rocks, Google, Bing and AI crawlers. In the Cloudflare data, Google and Bing received pages in 284 to 312 milliseconds rather than 1.9 to 4 seconds.

Search Console shows the effect too, although so far for just one day. Its November 10 export runs through November 8, the day of the move. On that day, Google waited an average of 69 milliseconds for a response from kevinkyburz.ch and 148 milliseconds from scllauerz.ch. Google had not been faster on any other day running EmDash.

The catch with one shared location: a queue can form

When all uncached requests reach one Durable Object, the next question almost asks itself: how much can it handle?

On November 9, I load-tested the demo first, then kevinkyburz.ch. Every uncached request received a fresh query string. The tests ran from Zurich for 15 seconds at each load level, with up to 160 concurrent requests.

Illustration: A long, patient queue of visitors and crawlers stretches around the corner outside the shared house with its glowing crystal roof. The orange EmDash character hands out finished pages one by one through a small counter in the front door, while the database helps from the next window. In the right foreground, a bright kiosk hands out pre-packaged pages without a wait.
Cached pages are available instantly at the kiosk. Everything else waits at the single counter where EmDash generates pages one by one.

The result:

  • The demo managed around 120 pages per second without caching.
  • kevinkyburz.ch managed around 20 pages per second with its more demanding real pages.
  • Across both tests, there was not a single error.

Although no request failed, responses became much slower under heavy load as requests queued up before processing. With 160 concurrent requests, the 95th percentile was around 2.5 seconds on the demo and 11.4 seconds on kevinkyburz.ch.

How much the setup can handle depends mainly on the computation EmDash needs to generate a page; the size of the finished HTML alone tells us little. My earlier estimate of 20 to 50 uncached pages per second only held at the lower end for kevinkyburz.ch.

This does not affect cached pages. They never reach the Durable Object: the small front Worker serves them directly from the cache. Things only get tight with many simultaneous cache misses, for example after a large cache purge, a deployment or a surge of traffic to previously unvisited pages. These short tests with a single load generator are not a guarantee of sustained operation under that load, though.

What was still unfinished after the first successful page load

I was pleased with the fast home page, but it said little about whether migrations, the admin or the next update would work. Further testing uncovered several problems I first had to solve for reliable operation.

Only the small Worker should sit in front

In the first version, the entire 19 MB bundle was still in the front Worker, so cold starts took 1.3 to 2.0 seconds, practically as long as before. Besides putting the database in the right place, I also needed a small front Worker that could start quickly at many locations.

Keeping it warm was the starting point, not the goal

In the first tests, loading the bundle took 81 milliseconds. But the first render needed 388 milliseconds of CPU time rather than the 65 to 120 milliseconds it needed when warm.

In early November, a cron or alarm running once a minute did not reliably keep the instance warm. So I initially used a setInterval and an alarm every 20 seconds that rendered the home page internally.

On November 9, Cloudflare announced in its changelog that, after a pause, a Durable Object will start on the machine that last ran it whenever possible. Its data then does not need to be loaded again, which should make cold starts less frequent. If that proves sufficient in practice, my timer may no longer be needed.

Since November 10, I have therefore been testing the opposite on kevinkyburz.ch: no keep-alive timer, no artificial page rendering. Only an alarm once a minute remains for background jobs.

Five cache misses, measured around seven minutes after another deployment, delivered the complete response in a median of 118 milliseconds. The response headers arrived after 114 milliseconds.

That is promising, but it is not yet proof that keeping the instance warm is generally unnecessary. This is a small sample of browser fetch requests, not navigation TTFB or a week of operation. I did not measure the very first request during the version change. The one-minute alarm still loads the application, and there was real traffic between measurements. I have therefore not demonstrated a complete cold start after total inactivity. My measurements do not yet establish whether Cloudflare's new placement behaviour is responsible either.

Deployments need a deliberate process

With the instance kept continuously warm at the time, deferred did not deliver the hoped-for benefit because the old code kept running until max_delay expired, which in my case was five minutes. Then a visitor still ended up taking the cold start.

So I switched to immediate. The Durable Object then switches to the new version immediately. Right after deploying, I request the page myself so the slow first request falls on me rather than a visitor. The downside: requests in progress during the switch can be interrupted, such as a long-running import or an upload.

A Durable Object does not eliminate cold starts entirely, then. But they happen less often and take less time. With D1, EmDash starts again at each visitor location after a deployment or a pause, and each of those starts took more than a second in my tests. Inside the Durable Object, EmDash starts in just one location, and that start was significantly faster too.

The cache is not automatically populated worldwide

The Workers Cache does not behave like the zone cache. The zone's Cache Rules and Cache Reserve do not apply here.

A hit in Frankfurt does not mean the page is ready everywhere else. In my tests, I subsequently saw hits in Paris, Vienna, Stockholm and Milan, but not in Tokyo, Sydney or São Paulo. I would not treat that as guaranteed distribution.

Illustration: A miniature world diorama with several cache kiosks. Beside the fully stocked European kiosk, the orange EmDash character proudly places the last finished page on a shelf, then looks in surprise towards distant kiosks near Tokyo, Sydney and São Paulo, where the shelves are empty and individual visitors are waiting.
The page is ready in Frankfurt, but the shelves in Tokyo, Sydney and São Paulo are still empty.

The first uncached request from a distant region therefore has to travel all the way to the Durable Object in Europe. That took around 850 milliseconds from Tokyo and around 940 from São Paulo. In my tests, that was still faster than D1 with a cold Worker there, but a long way from the best European timings.

Cache hits, by contrast, had worldwide medians of 6 to 38 milliseconds.

The technical hurdles: migrations, the admin and transactions

Internal tables blocked a migration

SQLite inside a Durable Object contains internal tables such as _cf_KV, where access fails with SQLITE_AUTH. Kysely, the database abstraction used by EmDash, ran into this when it tried to read the columns of every table before pending migrations.

This did not show up initially on kevinkyburz.ch because no migration was pending during the move. On linguadash.dev, however, the migration failed, and EmDash waited 30 seconds before trying again. During that time, pages returned status 200 but had no content.

A custom introspector fixes the problem by skipping tables with the _cf_ prefix. Without this fix, kevinkyburz.ch could also have gone down on the next EmDash update that introduced a migration.

One changed row was counted as two

During the admin test on November 9, creating a field failed because my dialect reported UPDATE results using rowsWritten. This also counts index entries written, so one changed row became a value of 2. EmDash expected exactly 1 and aborted.

With SELECT changes(), EmDash gets the correct count. The same bug had already been fixed in the official Durable Object adapter in #3633.

Transactions needed a closer look

When deleting a collection, I hit a direct transaction that caused a 500 error without the official adapter's safeguards. The collection got stuck on ‘Deleting’. For this path, transactionSync now handles the actual transaction.

That does not mean the entire dialect has general transaction support, though. Elsewhere, EmDash uses its withTransaction helper to run the steps sequentially, as it does with D1. If such a sequence is interrupted, changes can remain incomplete.

What worked after the fixes

After the fixes, creating fields and deleting a collection along with its content worked. Setting up passkeys, signing out and back in, and uploading, serving and deleting images also worked in the prototype.

On kevinkyburz.ch, I additionally tested a complete workflow through the API: create a collection and field, create content, publish it and then delete it again.

What it costs to run EmDash in a Durable Object instead of with D1

The first version kept all three Durable Objects awake permanently. At 128 MB, that amounts to around 332,000 GB-seconds per month per website. This unit measures memory allocation over time; the Workers Paid plan includes 400,000 GB-seconds.

One permanently warm website would therefore fit within this allowance. For three sites, the calculation comes to around $12.50 on top of the $5 base price, excluding any other usage. This is an estimate for the previous always-warm setup, not a measured monthly bill.

Since November 10, the experiment on kevinkyburz.ch has removed artificial page renders and the keep-alive timer. An alarm once a minute rather than every 20 seconds also means 1,440 instead of 4,320 scheduled alarms per day.

Two thirds fewer alarms does not mean two thirds lower costs, though. Real traffic, background work, billable duration and other use of the allowance matter too. A billing comparison will have to show what the change actually saves.

What I currently give up in return

The speed is not free, even if the Cloudflare bill remains modest.

Uncached requests have a central destination. Visitors from overseas have to reach Europe. The cache can avoid that trip, but only on a hit.

The admin and visitors share the same computing capacity. With 40 concurrent uncached frontend requests, the median for an admin request rose from 0.10 to 1.6 seconds. The highest measured value was 3.5 seconds. A surge of website traffic can therefore slow down editors too.

The move required a custom export. wrangler d1 export failed on the FTS5 full-text search tables.

Recovery has only been partially tested. Daily exports to R2 are in place. Durable Objects also support restoring to a point within the past 30 days. I tried that with a test post: recovery worked, although Cloudflare reports error 1101 because the Durable Object deliberately restarts. I have not yet tested a complete recovery from the R2 export, however. The next nightly R2 backup after ending artificial warming has not yet been verified live either. For more on backups in general, see my article on EmDash backups.

The custom code remains my responsibility. I need to recheck the database dialect, cache provider and front Worker with every EmDash update.

Sandboxed plugins do not work in this setup. Their data bridge in EmDash is hard-wired to D1. Without D1, they can neither save settings nor read content through it. That is not a minor cosmetic issue: it rules this setup out for sites that need them. kevinkyburz.ch and scllauerz.ch do not use sandboxed plugins.

The last points in particular mark the difference between a successful experiment and a solution I could recommend others adopt without hesitation.

Who this is worth the effort for

If almost all your visitors come from one region, your pages are reliably cached there and you rarely publish or deploy, I would stay with D1. It is simpler to operate and provides built-in recovery through Time Travel; these measurements alone would not persuade me to rebuild.

Anyone who needs sandboxed plugins cannot use my setup at the moment anyway. There is also the responsibility for custom infrastructure extensions: you need to be able to test their migrations and recovery.

The approach becomes more interesting when several of these points apply:

  • Your website has little traffic, so Workers go cold between visits.
  • Visitors come from different regions where the cache is still empty.
  • You publish or deploy frequently and need to refill the cache afterwards.
  • Search engines and other crawlers request many rarely read pages.
  • Your pages need many database queries, for menus, widgets and translations, for example.

Many of these apply to my three websites. The trip from overseas to Europe remains a drawback, but once the visitor reaches the destination, they no longer have to wait for a large Worker cold start and a whole series of separate network queries to the database as well.

What comes next

After my post on X, many people wanted to know whether this setup would make its way into EmDash itself. I have already discussed it privately with Matt Kane, but I am not publishing the code at the moment: this is my own experiment with clear limitations, not yet a ready-to-use adapter.

For EmDash, this would be an architectural change. That is why I have submitted an official proposal, an RFC. Before it becomes part of EmDash, it needs a design review and answers to the outstanding operational questions. In particular, the sandboxed plugin data bridge that is hard-wired to D1 needs to be addressed. You can join the discussion on GitHub.

Meanwhile, I am working on the next step: read replicas of the database in other regions. Uncached requests from Asia or Australia would then no longer have to travel to Europe.

A first experiment with regional read replicas

I tested this on November 9. Because Cloudflare's built-in read replication could not yet be deployed, I built my own replicas. When warm, they saved 75 to 210 milliseconds per uncached request in Singapore and Sydney.

The first request took about a second, though, and after publishing, each replica remained stale until the next synchronisation. I cleared the test replicas afterwards: despite the shorter trips, reliably distributing up-to-date content across several regions remains a separate challenge.

First, I therefore want to shore up the basics: longer browser measurement runs, a full period in Search Console, the actual costs without artificial warming and a tested recovery from the R2 export.

In my measurements so far, most of the waiting happened in the network trips and cold starts surrounding the actual page computation. With the same application in a different setup, the global median for uncached HTML requests on scllauerz.ch fell from 2,837 to 291 milliseconds. The improvement on the other two websites is smaller, but still substantial.

Being able to find and improve causes like these reinforces my commitment to working on EmDash as a maintainer, even if not everything is fast or finished yet. Together with the people who report bugs, measure performance and submit pull requests, we can keep improving it.

I originally just wanted to find out why the first visitor had to wait so long. Now that wait is much shorter on my three websites, and we have a clearer idea of what to work on next.