From cf240addeaaaa969ac268e4b5d965b2a6e88983a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 11 Aug 2026 16:09:37 +0200 Subject: [PATCH] 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. --- .../src/app/api/image-proxy/[id]/route.ts | 5 ++++- packages/image-proxy/src/index.ts | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts index 1235d54a4..ae0f1c4b6 100644 --- a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts +++ b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts @@ -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 } : {}), }, }); }; diff --git a/packages/image-proxy/src/index.ts b/packages/image-proxy/src/index.ts index 445547992..f7e710cd7 100644 --- a/packages/image-proxy/src/index.ts +++ b/packages/image-proxy/src/index.ts @@ -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>["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 {