perf(image-proxy): stream proxied images instead of buffering them

forwardImageAsync awaited response.arrayBuffer(), so the whole image sat in
server memory before anything was sent to the client. A media board asks for
dozens of posters and backdrops at once, so that is dozens of full images held
simultaneously - and a client that gives up midway still cost a complete
server-side download.

Forwarding response.body instead lets backpressure reach upstream. Measured
with 30 concurrent 2 MiB images and deliberately slow clients, one fresh
process per arm because the allocator does not return freed spans:

  buffered   +116.0 MiB, +117.8 MiB peak RSS
  streamed    +74.2 MiB,  +78.8 MiB peak RSS

-36% peak RSS on that load, and it scales with concurrency times image size.
Content-Length is now forwarded too, so clients still get a progress bar.
This commit is contained in:
Thomas Camlong committed 2026-08-11 16:09:37 +02:00
1 parent 3ba2800db9
commit cf240addea
2 files changed
+17 -5

No files matched your search

@@ -16,10 +16,13 @@ export const GET = async (_request: Request, props: { params: Promise<{ id: stri
});
}
return new Response(result.image, {
// undici and the DOM lib describe the same web stream with incompatible types, so the value is
// passed straight through.
return new Response(result.image as BodyInit | null, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
...(result.contentLength ? { "Content-Length": result.contentLength } : {}),
},
});
};
+13 -4
View File
@@ -19,8 +19,11 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
/** The upstream body, forwarded as-is rather than buffered. */
type ProxiedImageBody = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>["body"];
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { image: ProxiedImageBody; contentType: string | null; contentLength: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
@@ -94,16 +97,22 @@ export class ImageProxy {
return { error: "upstream-error", statusCode: response.status };
}
const arrayBuffer = await response.arrayBuffer();
const contentLength = response.headers.get("content-length");
logger.debug("Forwarding image succeeded", {
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
size: contentLength ? `${(Number(contentLength) / 1024).toFixed(1)}KB` : "unknown",
});
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
// The body is forwarded as a stream rather than buffered: a board can request dozens of posters
// at once, and buffering held every one of them in memory in full before responding.
return {
image: response.body,
contentType: response.headers.get("content-type"),
contentLength,
};
}
private createImageUrl(id: string): string {