Skip to content

perf(og): stream-parse <head> instead of buffering full 2 MB body - #1611

Open
Sravanjangam wants to merge 1 commit into
supermemoryai:mainfrom
Sravanjangam:fix/og-streaming-head-only
Open

perf(og): stream-parse <head> instead of buffering full 2 MB body#1611
Sravanjangam wants to merge 1 commit into
supermemoryai:mainfrom
Sravanjangam:fix/og-streaming-head-only

Conversation

@Sravanjangam

Copy link
Copy Markdown
Contributor

Fixes #1610.

Problem

apps/web/app/api/og/route.ts runs a regex-driven OG scraper on a 2 MB buffer when only the document <head> (~30 KB in practice) is actually needed. readBoundedText already streams the response, but it buffers every chunk into an array, merges them into a single Uint8Array, and then TextDecoders the whole thing before returning. The function's own comment says "OG parsing only needs <head>, so cap the read rather than buffering the whole body" — but the implementation does the opposite.

How it effects

Per /api/og request on the current code:

  • Memory: ~6–8 MB peak (2 MB chunks: Uint8Array[] + 2 MB merged buffer + 2–4 MB UTF-16 string after TextDecoder).
  • CPU: 6 regex matches scan the full 2 MB string — ~12 MB of byte scanning — to extract meta tags that exist in the first 20–40 KB.
  • Network: the full body is downloaded end-to-end even though everything after </head> is discarded.

At link-preview scale (a chat app unfurling 20 URLs in one message) peak RSS in the worker approaches 150–200 MB and the slowest fetch gates every other preview. The 2 MB cap is a correctness cap that doubles as the performance baseline, so making it a head-only cap of 64 KB is a free win.

Solution

Replace readBoundedText with readHeadText:

  • Reads chunks into a small growing string buffer (cap 64 KB).
  • Scans for </head> (case-insensitive) after every chunk.
  • Cancels the response reader and returns the partial string as soon as </head> is seen.
  • Returns null (→ existing 413 path) if the cap is hit before </head> closes.

The 6 regex patterns in processHtml are unchanged — they just run over a 30–40 KB string instead of 2 MB, which is the actual win. The two call sites (main response and the single-level-redirect path) were updated to use the new function name; their error handling and status codes are unchanged.

Tests

Apps/web has no test runner wired in (per recent maintainer preference), so the verification is a manual smoke harness run with bun:

PASS  small HTML with </head> closes early
PASS  large body, </head> in first 200 bytes (chunked delivery)
PASS  </head> split across two chunks
PASS  case-insensitive match: </HEAD>
PASS  no </head> within cap → null
PASS  empty body → null
6 passed, 0 failed

Cases cover the happy path, realistic chunked delivery, mid-tag chunk boundaries, case-insensitive matching, the cap-exceeded path, and the empty-body edge case. The harness is in /tmp/prs/og-head-smoke.mjs (not part of this PR) and can be re-run any time.

Environment

  • macOS 26.1 (arm64) · bun 1.4.0 · node v26.7.0
  • Biome lint clean (one pre-existing extractImageUrl unused-function warning on main is unchanged — baseline parity, not a regression from this diff)
  • tsc --noEmit clean for the touched file
  • Branch fix/og-streaming-head-only, commit b95eebe2680e

readBoundedText buffered the entire response up to MAX_HTML_BYTES (2 MB)
just to regex-scan it for OG meta tags that live in the first ~30 KB.
At link-preview scale the peak RSS per request was ~6-8 MB and the
first byte of any preview waited on a full-body fetch.

readHeadText reads chunks into a small growing buffer, scans for </head>
after each chunk, and cancels the reader as soon as it's seen. The cap
drops to 64 KB; OG tags live entirely within that bound in practice.
The 6 regex patterns in processHtml are unchanged and now run over a
~30 KB string instead of 2 MB.

Fixes supermemoryai#1610.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OG route buffers full 2 MB response though only <head> is needed

1 participant