Speed Lab: many small files, HTTP/1.1 and HTTP/2

The same CSS, scripts and images as 62 requests or 40, over HTTP/1.1 and HTTP/2 on three kinds of slowed network: first paint, the finish and the server's part.

IntermediateHow-to13 min readUpdated October 30, 2026

In this guide
  1. The question
  2. The pages
  3. How the tests were run
  4. Results on a shaped network, with gzip
    1. HTTP/1.1: waiting for a connection
    2. HTTP/2: one connection, and the order of the files
  5. Without compression: a trap
  6. The same pages with the other two methods
  7. What this test does not show
  8. What to do
  9. Run it yourself
  10. Mistakes to avoid
  11. Check your own page
  12. Sources

The question

For years the advice for HTTP/1.1 was to combine files, because a browser opens about six connections to a host and makes the other requests wait. HTTP/2 puts many requests on one connection, and the HTTP/2 and HTTP/3 guide says that merging files matters less now. How much less? I built one page two ways and served it over both protocols.

The pages

Both pages hold the same bytes. Only the number of files differs.

The CSS is 59,354 bytes and the JavaScript 39,071 bytes in both pages, and the thumbnails total 63,375 bytes. The many-files page has 794 more bytes of HTML for its extra tags. The scripts only set variables, and the CSS is mostly generated rules, so neither does real work. The text files are sent with gzip unless a section says otherwise: the page and its files then weigh about 93 KB (many files) and 87 KB (bundled) over HTTP/1.1.

How the tests were run

I served the pages four ways: HTTP/1.1 over plain HTTP from a small Node server, HTTP/1.1 over TLS, HTTP/2 from Node's built-in http2 module, and HTTP/2 from Hypercorn, a Python server. Hypercorn's code schedules its responses with a priority tree, and in the timelines it sent the style sheet first. Node's server finished the bundled style sheet and the bundled script together.

The slow network matters more here than in other tests, so I slowed it down three ways, each time with Lighthouse 13.5.0 and nine runs per page, taken in turns. The method page explains the settings.

Results on a shaped network, with gzip

"Whole page finished" is the time at which the last request ended. First paint and Largest Contentful Paint are Lighthouse's. Medians of nine runs, with the lowest and highest run in brackets.

Served asPageFirst paintLargest Contentful PaintWhole page finished
HTTP/1.162 requests926 ms (897 to 944)1,361 ms (1,325 to 1,402)2,134 ms (2,115 to 2,150)
HTTP/1.140 requests591 ms (548 to 618)743 ms (724 to 787)1,616 ms (1,588 to 1,660)
HTTP/2, Node62 requests780 ms (752 to 825)1,240 ms (975 to 1,294)1,155 ms (1,129 to 1,185)
HTTP/2, Node40 requests871 ms (848 to 888)1,141 ms (902 to 1,217)1,134 ms (1,115 to 1,146)
HTTP/2, Hypercorn62 requests797 ms (777 to 815)921 ms (869 to 955)1,160 ms (1,154 to 1,180)
HTTP/2, Hypercorn40 requests737 ms (710 to 892)951 ms (898 to 987)1,141 ms (1,123 to 1,159)

Five things stand out.

  1. Over HTTP/1.1 the extra files cost something at every step. The 40-request page painted 335 ms sooner, showed its first thumbnail (the Largest Contentful Paint here) 618 ms sooner and finished 518 ms sooner. The ranges do not overlap.
  2. Over HTTP/2 the number of files hardly mattered at the finish: 1,155 against 1,134 ms from Node, and 1,160 against 1,141 ms from Hypercorn.
  3. HTTP/2 finished the 62-request page almost a second sooner than HTTP/1.1: 979 ms from Node and 974 ms from Hypercorn.
  4. The first paint was not a clean win for HTTP/2. The bundled page painted soonest of all over HTTP/1.1, at 591 ms, against 737 and 871 ms over HTTP/2. With the many-files page the order was the other way round (926 ms against 780 and 797 ms).
  5. The two HTTP/2 servers disagreed about the first paint of the bundled page, 871 ms from Node and 737 ms from Hypercorn. The reason is below.

HTTP/1.1: waiting for a connection

The timeline from the Lighthouse reports shows what the extra files did. In the many-files page the browser asked for all twelve style sheets at about 360 ms. Six went out at once and six waited for a free connection, so the last style sheet arrived at about 860 ms. The browser held the thumbnails back until then, and only then asked for them, 36 requests over six connections, which took until about 1,990 ms. The last scripts had to wait for the thumbnails and arrived at 2,134 ms.

The bundled page asked for its style sheet, its script and the first four thumbnails together, at about 380 to 430 ms. Its style sheet arrived at 538 ms, and the thumbnails followed in rounds until 1,616 ms. Each round on a connection costs a round trip, and fewer requests mean fewer rounds, which is why bundling won at every step.

HTTP/2: one connection, and the order of the files

Over HTTP/2 all 62 requests went out together, between 520 and 590 ms, on one connection. Hypercorn sent the style sheets first, then the scripts, then the thumbnails. Node's server did something different with the bundled page: it sent the style sheet and the script together, and they finished at the same moment, 828 ms, 137 ms after Hypercorn's style sheet. That is the difference in the first paint of the bundled page: 871 ms from Node against 737 ms from Hypercorn.

The number of files is therefore not what an HTTP/2 page depends on. The order in which the server sends them is. I tested two servers, and I did not find out how Node decides. Andy Davies keeps a list of content delivery networks and hosts that shows how differently they handle priorities: some send in the browser's order and some do not. His results are from 2018 and 2019, so they say nothing about today's services. The point stands that a CDN or host is part of the result.

The bundled page on HTTP/1.1 still painted sooner than on either HTTP/2 server. My reading is that, in the model, a new connection starts slowly, and HTTP/1.1 opened several, each with its own start, while HTTP/2 had one, shared by the style sheet, the script and the first thumbnails. A host can start connections with a different window than the model's, so treat this one as a property of the model.

HTTP/2 also sends less. It compresses the request and response headers, and over the 62 requests it sent about 8 KB less than HTTP/1.1 did, on a page of 176 KB without compression.

Without compression: a trap

My first runs sent the text files as they are, so that the number of files was the only thing that changed. The result looks different:

Served asPageFirst paintLargest Contentful PaintWhole page finished
HTTP/1.162 requests1,047 ms (979 to 1,060)1,375 ms (1,284 to 1,451)2,216 ms (2,181 to 2,248)
HTTP/1.140 requests1,208 ms (1,012 to 1,327)1,208 ms (1,012 to 1,327)1,788 ms (1,740 to 1,858)
HTTP/2, Node62 requests1,072 ms (1,040 to 1,115)1,596 ms (1,320 to 1,651)1,531 ms (1,500 to 1,552)
HTTP/2, Node40 requests1,234 ms (1,052 to 1,262)1,374 ms (1,272 to 1,512)1,512 ms (1,507 to 1,533)
HTTP/2, Hypercorn62 requests1,070 ms (1,044 to 1,092)1,272 ms (1,256 to 1,310)1,527 ms (1,509 to 1,550)
HTTP/2, Hypercorn40 requests1,075 ms (1,057 to 1,089)1,299 ms (1,269 to 1,326)1,527 ms (1,521 to 1,541)

Over HTTP/1.1 the merged page now painted 161 ms later than the page with 62 requests (1,208 ms against 1,047 ms), although it still finished sooner. The reason is size. One style sheet of 59 KB has to grow through slow start on one connection and shares the link with the script and the first thumbnails. Twelve small sheets on six connections each fit in the first round trip of their connection. With gzip the style sheet is 8 KB and the problem goes away. A test of files that real servers would compress is a poor test, which is why the table above comes first.

The same pages with the other two methods

Here is the first paint of the many-files page minus that of the bundled page, for each way of slowing the network down, on the pages without compression. A positive number means the extra files made the page paint later.

Served asAppliedSimulatedShaped
HTTP/1.1+309 ms+303 ms−161 ms
HTTP/2, Node+109 ms−11 ms−162 ms

On the finish, applied throttling put the cost of the extra files over HTTP/1.1 at 1,995 ms (6,798 ms against 4,803 ms) and the shaped network at 428 ms. Simulated throttling does not report a finish time.

Three things explain the differences.

None of the three is the truth. They are three models, and a result that matters should be checked with more than one.

What this test does not show

What to do

  1. Find out which protocol serves your site. Open the Network panel, add the Protocol column and look for h2 or h3. Or run curl -sI --http2 https://your-site.example | head -1 and look for HTTP/2. See HTTP/2 and HTTP/3 for how to switch it on.
  2. If it says `http/1.1`, fix that before you touch the files. It is a setting at the host or the CDN, and it changes how every request is carried.
  3. Count the files in front of the first paint. Style sheets in the head and scripts without defer hold the page back. A dozen small style sheets in the head are a dozen requests before the first paint.
  4. Combine what every page needs into a few files, not one. Group by how often the files change and where they are used, so that a small change does not throw away the whole cache. See bundle size and code splitting.
  5. Compress the text files. In these tests it decided the first paint more than the number of files did. See the compression test.
  6. Ask your host or CDN how it orders the files it sends. The style sheet should go before the images. Some servers do not do it.
  7. Test with the network slowed down, and look at the timeline, not only the scores. See Chrome DevTools Network panel.

Run it yourself

To repeat the tests you need three files and a folder thumbs with 36 small JPEG files named t0.jpg to t35.jpg. Mine were 120 by 80 pixels and about 1.8 KB each.

1. The pages. Save this as make-pages.mjs and run node make-pages.mjs thumbs. It writes pages/many-files and pages/bundled, the same pages I tested.

// make-pages.mjs: node make-pages.mjs <folder holding t0.jpg … t35.jpg>
// Writes pages/many-files (62 requests) and pages/bundled (40 requests): the same CSS, scripts and thumbnails, in 12 + 12 files or in 1 + 1.
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs';

const thumbs = process.argv[2];
const hex = (n) => (n % 256).toString(16).padStart(2, '0');
const rule = (i, j) => `.c${i}-${j}{color:#${hex(i * 37 + j * 11)}${hex(i * 53 + j * 7)}${hex(i * 29 + j * 13)};margin:${j % 7}px;padding:${(i + j) % 9}px;border:1px solid #ccc;font-size:${12 + (j % 6)}px}\n`;
const css = Array.from({ length: 12 }, (_, i) => Array.from({ length: 60 }, (_, j) => rule(i, j)).join(''));
css[0] = '*{box-sizing:border-box}body{margin:0;font:16px/1.5 Arial,sans-serif;color:#222}header{padding:12px 16px;background:#0b3a6e;color:#fff}main{max-width:720px;margin:0 auto;padding:0 16px}h1{font-size:24px}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.grid img{width:100%;height:auto;display:block}\n' + css[0];
const js = Array.from({ length: 12 }, (_, i) => `/* module ${i} */\n` + Array.from({ length: 160 }, (_, k) => `window.__m${i}_${k}=${k * i};\n`).join(''));
const images = Array.from({ length: 36 }, (_, i) => `<img src="img/t${i}.jpg" width="120" height="80" alt="Item ${i}">`).join('');
const page = (head) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Many files test</title>
${head}</head><body><header>Example Shop</header><main><h1>Spring collection</h1><div class="grid">${images}</div></main></body></html>`;

for (const name of ['many-files', 'bundled']) {
  const dir = `pages/${name}`;
  mkdirSync(`${dir}/img`, { recursive: true });
  for (let i = 0; i < 36; i++) copyFileSync(`${thumbs}/t${i}.jpg`, `${dir}/img/t${i}.jpg`);
  if (name === 'many-files') {
    css.forEach((text, i) => writeFileSync(`${dir}/c${i}.css`, text));
    js.forEach((text, i) => writeFileSync(`${dir}/m${i}.js`, text));
    writeFileSync(`${dir}/index.html`, page(css.map((_, i) => `<link rel="stylesheet" href="c${i}.css">`).join('') + js.map((_, i) => `<script src="m${i}.js" defer></script>`).join('')));
  } else {
    writeFileSync(`${dir}/all.css`, css.join(''));
    writeFileSync(`${dir}/all.js`, js.join(''));
    writeFileSync(`${dir}/index.html`, page('<link rel="stylesheet" href="all.css"><script src="all.js" defer></script>'));
  }
}

2. The network shaper. Save this as netshape.mjs. It listens on one port and passes everything to a server on another port, slowing it down as described above. You can put it in front of any local server.

// netshape.mjs <listenPort> <targetPort> : a TCP proxy that stands in for a slow mobile network between a browser and a local server.
// What it models:
//  - a round trip time (SHAPE_RTT_MS, default 150), split evenly between the two directions, for every byte;
//  - the TCP handshake: the first bytes of a new connection wait one extra round trip;
//  - one download link (SHAPE_DOWN_KBPS, default 1638.4 = 1.6 Mbit/s), shared by all connections: each connection takes its turn, one packet at a time;
//  - slow start: a new connection may send 10 packets in its first round trip, and twice as many in each full round trip after that (SHAPE_SLOWSTART=0 turns this off).
// The bytes of one connection leave in the order the server wrote them, so a server that sends the most important file first is rewarded, and one that mixes files is not.
// It does not model packet loss, queues that overflow, or the upload link (requests are tiny).
import net from 'node:net';
import { performance } from 'node:perf_hooks';

const [listenPort, targetPort] = process.argv.slice(2).map(Number);
const RTT = Number(process.env.SHAPE_RTT_MS || 150);
const BYTES_PER_MS = (Number(process.env.SHAPE_DOWN_KBPS || 1638.4) * 1024) / 8 / 1000;
const SLOWSTART = process.env.SHAPE_SLOWSTART !== '0';
const MSS = 1448;
const INIT_CWND = 10 * MSS;
const MAX_CWND = 1024 * 1024;

const conns = new Set();
let credit = 0;
let last = performance.now();
let turn = 0;
let timer = null;
const stats = { connections: 0, bytesDown: 0 };

function makeConn(client) {
  const c = {
    client, server: net.connect(targetPort, '127.0.0.1'),
    queue: [], queued: 0,            // bytes the server has written and the link has not carried yet
    out: [],                         // packets on their way to the browser: { at, buf }
    up: [],                          // chunks on their way to the server: { at, buf }
    cwnd: INIT_CWND, windowStart: 0, sent: 0, lastSend: -1e9, firstUp: true, serverDone: false, closing: false,
  };
  c.server.setNoDelay(true); client.setNoDelay(true);
  stats.connections++;
  client.on('data', (buf) => {
    const now = performance.now();
    let at = now + RTT / 2 + (c.firstUp ? RTT : 0); c.firstUp = false;      // the handshake round trip comes before the first bytes
    const prev = c.up.length ? c.up[c.up.length - 1].at : 0;
    c.up.push({ at: Math.max(at, prev), buf });
    schedule();
  });
  c.server.on('data', (buf) => {
    if (!c.queued && performance.now() - c.lastSend > RTT) { c.windowStart = performance.now(); c.sent = 0; }   // sending starts again after a pause
    c.queue.push(buf); c.queued += buf.length;
    schedule();
  });
  client.on('end', () => c.server.end());
  client.on('close', () => { c.server.destroy(); conns.delete(c); });
  client.on('error', () => { c.server.destroy(); conns.delete(c); });
  c.server.on('end', () => { c.serverDone = true; schedule(); });
  c.server.on('close', () => { c.serverDone = true; schedule(); });
  c.server.on('error', () => { c.serverDone = true; schedule(); });
  conns.add(c);
}

function canSend(c, now) {
  if (!SLOWSTART) return true;
  if (now - c.windowStart >= RTT) {                  // a round trip has passed: the acknowledgements are back
    if (c.sent >= c.cwnd - MSS) c.cwnd = Math.min(c.cwnd * 2, MAX_CWND);
    c.windowStart = now; c.sent = 0;
  }
  return c.sent < c.cwnd;
}

function take(c, max) {
  const out = [];
  let need = Math.min(max, c.queued);
  while (need > 0) {
    const head = c.queue[0];
    if (head.length <= need) { out.push(head); c.queue.shift(); need -= head.length; c.queued -= head.length; }
    else { out.push(head.subarray(0, need)); c.queue[0] = head.subarray(need); c.queued -= need; need = 0; }
  }
  return Buffer.concat(out);
}

function busy() { for (const c of conns) if (c.queued > 0 || c.out.length || c.up.length || (c.serverDone && !c.closing)) return true; return false; }
function schedule() { if (!timer) timer = setTimeout(tick, 1); }

function tick() {
  timer = null;
  const now = performance.now();
  credit = Math.min(credit + (now - last) * BYTES_PER_MS, 4 * MSS); last = now;
  // 1. packets and requests whose travel time is over are delivered, in order
  for (const c of conns) {
    while (c.out.length && c.out[0].at <= now) { const p = c.out.shift(); if (!c.client.destroyed) c.client.write(p.buf); }
    while (c.up.length && c.up[0].at <= now) { const p = c.up.shift(); if (!c.server.destroyed) c.server.write(p.buf); }
  }
  // 2. the link carries packets, connections taking turns
  const list = [...conns].filter((c) => c.queued > 0);
  let progress = list.length > 0;
  while (progress && credit >= 1) {
    progress = false;
    for (let i = 0; i < list.length; i++) {
      const c = list[(i + turn) % list.length];
      if (c.queued <= 0 || !canSend(c, now)) continue;
      const size = Math.min(MSS, c.queued);
      if (credit < size) { progress = false; break; }
      const pkt = take(c, size);
      credit -= pkt.length; c.sent += pkt.length; c.lastSend = now; stats.bytesDown += pkt.length;
      const prev = c.out.length ? c.out[c.out.length - 1].at : 0;
      c.out.push({ at: Math.max(now + RTT / 2, prev), buf: pkt });
      progress = true;
    }
  }
  turn++;
  // 3. a connection the server has finished with is closed once its last packet is delivered
  for (const c of conns) if (c.serverDone && !c.closing && c.queued === 0 && c.out.length === 0) { c.closing = true; if (!c.client.destroyed) c.client.end(); }
  if (busy()) schedule();
}

net.createServer((client) => makeConn(client)).listen(listenPort, '127.0.0.1', () => console.error(`netshape: 127.0.0.1:${listenPort} → ${targetPort}, RTT ${RTT} ms, ${(BYTES_PER_MS * 1000 * 8 / 1024).toFixed(1)} Kbps, slow start ${SLOWSTART ? 'on' : 'off'}`));
process.on('SIGUSR2', () => console.error(JSON.stringify({ ...stats, open: conns.size })));

I checked it against curl. With a 1 KB file behind it, the time to the first byte was 308 ms, which is two round trips (one for the connection, one for the request). A 1 MB file took 5.4 seconds. Do the same before you trust it.

3. The runner. Install Lighthouse with npm install lighthouse (it needs Node 22 or later). For the TLS and HTTP/2 modes, create a certificate once with openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj "/CN=127.0.0.1". Save this as lab-shaped.mjs and run node lab-shaped.mjs pages http1 9, then with https1 and http2 in place of http1.

// lab-shaped.mjs: node lab-shaped.mjs <folder holding one folder per variant> <http1|https1|http2> [runs]
// Serves the folders, puts the network shaper (netshape.mjs) in front of the server, runs Lighthouse on each page in turns
// and prints the median first paint and Largest Contentful Paint with the lowest and highest run.
// For https1 and http2 create a certificate once:  openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj "/CN=127.0.0.1"
import { createServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { createSecureServer } from 'node:http2';
import { createServer as createNetServer } from 'node:net';
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, extname, resolve } from 'node:path';
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';

const root = resolve(process.argv[2]);
const mode = process.argv[3] || 'http1';
const runs = Number(process.argv[4] || 9);
const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.jpg': 'image/jpeg' };
const variants = readdirSync(root).filter((d) => statSync(join(root, d)).isDirectory()).sort();

const handler = (req, res) => {
  let path = decodeURIComponent(new URL(req.url, 'http://x').pathname);
  if (path.endsWith('/')) path += 'index.html';
  const file = join(root, path);
  if (!file.startsWith(root) || !existsSync(file)) { res.writeHead(404); return res.end(); }
  const body = readFileSync(file);
  res.writeHead(200, { 'content-type': types[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store', 'content-length': body.length });
  res.end(body);
};
const tls = mode === 'http1' ? null : { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') };
const server = mode === 'http1' ? createServer(handler) : mode === 'https1' ? createHttpsServer(tls, handler) : createSecureServer({ ...tls, allowHTTP1: true }, handler);
await new Promise((done) => server.listen(0, '127.0.0.1', done));

// The shaper listens on a free port and passes everything to the server.
const shaperPort = await new Promise((done) => { const probe = createNetServer().listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => done(port)); }); });
const shaper = spawn(process.execPath, ['netshape.mjs', String(shaperPort), String(server.address().port)], { stdio: 'inherit' });
await new Promise((done) => setTimeout(done, 700));
const base = `${mode === 'http1' ? 'http' : 'https'}://127.0.0.1:${shaperPort}`;

// Lighthouse applies only the processor slowdown. Headless Chrome needs its frame rate limit off, or the first paint can be reported 1 to 2 seconds late.
const flags = ['--only-categories=performance', '--throttling-method=devtools', '--throttling.requestLatencyMs=0', '--throttling.downloadThroughputKbps=0',
  '--throttling.uploadThroughputKbps=0', '--throttling.cpuSlowdownMultiplier=4', '--output=json', '--quiet',
  '--chrome-flags=--headless=new --disable-frame-rate-limit --ignore-certificate-errors'];   // add --no-sandbox if you run as root in a container
const results = Object.fromEntries(variants.map((v) => [v, []]));
for (let i = 0; i < runs; i++) {
  for (const v of variants) {
    const { stdout } = await promisify(execFile)('npx', ['lighthouse', `${base}/${v}/`, ...flags], { maxBuffer: 1 << 28 });
    const audits = JSON.parse(stdout).audits;
    results[v].push({ fcp: audits['first-contentful-paint'].numericValue, lcp: audits['largest-contentful-paint'].numericValue });
  }
}
shaper.kill();
server.close();

const median = (xs) => [...xs].sort((a, b) => a - b)[xs.length >> 1];
for (const v of variants) {
  for (const [name, key] of [['first paint', 'fcp'], ['LCP', 'lcp']]) {
    const xs = results[v].map((r) => r[key]);
    console.log(v.padEnd(14), name.padEnd(12), `${Math.round(median(xs))} ms`, `(${Math.round(Math.min(...xs))} to ${Math.round(Math.max(...xs))})`);
  }
}

One warning from my own runs. In headless Chrome 153, with no network throttling applied by Lighthouse, the first paint was often reported late, at 1.3 or 2.5 seconds, whatever the network did. In my first 18 runs through the shaper, 14 were affected. Chrome's --disable-frame-rate-limit flag removed it: none of the 54 runs I kept had it. The flag makes each run slower, about 40 seconds instead of 23. If a first paint looks oddly constant from run to run, check for this.

Mistakes to avoid

Check your own page

Run the free audit and open the network requests list. Count the requests and note the protocol. If your page makes dozens of requests over HTTP/1.1, switch the protocol first, then combine the style sheets and scripts that every page needs, and run the audit again.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →