Skip to content

CheerioCrawler: extracted strings pin the entire page HTML in memory (V8 sliced strings) #3809

Closed as not planned

Description

@MrGeorgen

Which package is this bug report for? If unsure which one to select, leave blank

@crawlee/cheerio (CheerioCrawler)

Issue description

CheerioCrawler parses responses with htmlparser2, which builds DOM text and
attribute values as zero-copy substrings of the raw HTML source. In V8 these are
SlicedStrings: a tiny extracted string internally keeps a reference to the
entire page HTML it was sliced from.

That is harmless while the strings are transient. But the canonical Crawlee usage
pattern — extract a few small fields per page in the requestHandler and collect
them somewhere for the duration of the crawl (an array, a Map, …) — retains those
slices. Every retained string then pins its full source page in the heap:

  • page is 3 MB, you keep a 40-byte title → ~3 MB stay live
  • one retained string per page over a 10k-page crawl → gigabytes of
    live-but-invisible HTML → JavaScript heap out of memory

The failure is invisible in code review and at runtime until the OOM, and only
diagnosable via heap-snapshot forensics (retainer chain: object field →
sliced string → full <!DOCTYPE html>… body).

Real-world numbers: on a production crawl (~2,500 category pages of 2.3–4 MB
each) the run died with OOM. Heap snapshots showed ~1 GB of live heap consisting of
complete page HTML strings, held by 15,266 sliced strings in two plain string
fields of collected result objects (title: 7,176×, imgUrl: 8,090×); a later
snapshot showed the same counts at ~3× size, i.e. linear retention per page.
After copying extracted fields via Buffer.from(value, 'utf8').toString('utf8')
the same crawl stays flat at ~150–390 MB instead of climbing past 2.4 GB.

Steps to reproduce with the code sample below:

  1. run the crawler over many URLs with large pages
  2. observe heap usage growing by roughly one full page size per retained string
  3. replace the push with the detach(...) variant → heap stays flat

Note this is not limited to Cheerio APIs: regex capture groups over a kept
HTML/XML string (e.g. extracting URLs from a downloaded sitemap before enqueueing)
slice the same way, so a request queue can pin multi-MB sitemap documents for the
whole run, too.

Why report this against Crawlee rather than cheerio/htmlparser2: this was
reported to cheerio in 2013 (cheeriojs/cheerio#263) and closed by the htmlparser2
maintainer as a V8 issue ("if you consume multiple documents, add the fix
yourself"; upstream: https://issues.chromium.org/issues/41480525, unfixed since). Defensible for a parsing
library — most cheerio users consume one document. Crawlee's core use case is
consuming thousands of large documents and retaining small extracts from each,
so the sharp edge passes silently through two abstraction layers and surfaces as
an undebuggable OOM in user code.

Suggested resolution: detach by default. The exposed $ should copy
text/attribute values on extraction (e.g. Buffer.from(v, 'utf8').toString('utf8')).
The cost is a small allocation per extracted string — nanoseconds, and completely
dwarfed by the network I/O and parsing Crawlee already does per page. In exchange,
the dominant usage pattern (retain small extracts from many pages) stops OOMing
silently.

For users who genuinely need zero-copy extraction (huge extracts, hot loops), an
opt-out (detachExtractedStrings: false) would preserve today's behavior.

If changing the default is off the table, fallbacks in order of usefulness:

  1. opt-in option (detachExtractedStrings: true),
  2. an exported, documented detachString() helper in @crawlee/utils, so the fix
    has a canonical, discoverable name,
  3. at minimum a prominent docs warning on CheerioCrawler and in the scraping
    tutorials.

The current silent zero-copy default is a micro-optimization that costs Crawlee's
main audience gigabytes of heap and an undebuggable crash.

Code sample

import { CheerioCrawler } from 'crawlee';

const titles: string[] = [];

const crawler = new CheerioCrawler({
    requestHandler({ $ }) {
        // Looks like it stores ~40 bytes. Actually pins the whole page HTML,
        // because htmlparser2 node data are V8 sliced strings of the source.
        titles.push($('title').text());

        // Workaround — copying detaches the string from the source buffer:
        // const detach = (s: string) => Buffer.from(s, 'utf8').toString('utf8');
        // titles.push(detach($('title').text()));
    },
});

await crawler.run([/* many URLs pointing at large pages */]);
// Heap grows linearly with page count (~pageSize per retained string).
// Verify: take a heap snapshot and inspect retainers of the string fields.

Package version

crawlee@3.17.0

Node.js version

v26.4.0

Operating system

Linux x64 (Arch)

Other context

Related: cheeriojs/cheerio#263 (same root cause, closed as V8-specific in 2013),
V8 upstream ticket https://issues.chromium.org/issues/41480525 (
open since 2013). We have added our production heap-forensics numbers as a comment on that
V8 issue, but engine-side options (GC-time flattening, a runtime --no-string-slices flag,
an official flatten API) have been stalled there for over a decade — which is why a
mitigation at the Crawlee layer seems like the pragmatic place.

The SlicedString optimization applies to
substrings ≥ 13 characters, so virtually every extracted text/attribute is affected.

Activity

  1. added
    t-toolingIssues with this label are in the ownership of the tooling team.
    on Jul 2, 2026
  2. changed the title [-]CheerioCrawler: strings extracted via $().text()/.attr() pin the entire page HTML in memory (V8 sliced strings) — OOM on large crawls[/-] [+]CheerioCrawler: extracted strings pin the entire page HTML in memory (V8 sliced strings)[/+] on Jul 2, 2026
  3. barjin commented on Jul 2, 2026

    @barjin
    Member

    Thank you for the report @MrGeorgen !

    Imo this is not a Crawlee issue per se, as your script is the final consumer of the data - Crawlee just orchestrates the HTTP client and Cheerio to help you with scaling your scraping jobs.

    If you store the data in the Crawlee-idiomatic way into a Dataset (e.g., by calling crawlingContext.pushData({ title }) (docs)), Crawlee serializes the object with JSON.serialize(), which creates a deep copy of the string too (leading to the same minimal memory footprint as your Buffer-based workaround).

    I don't see a way of patching this in Crawlee without affecting the existing interface. Can you please share more details about your idea of the fix? Thanks!


    cc @janbuchar the v4 MemoryStorage doesn't do the JSON serialization anymore, storing the reference directly. This can lead to memory "leaks" as seen above. I'll play with that a bit more and possibly create a separate issue.

  4. MrGeorgen commented on Jul 2, 2026

    @MrGeorgen
    Author

    @barjin Using a Dataset is a good way of preventing this issue, thanks.

    However other users will likely also store the extracted data elsewhere and run into the same problem. The most safest fix would be to not expose the Cheerio api directly, but instead wrap every cheerio function which returns a string with the Buffer-workaround. I know it is hacky and not clean at all, but it is the only thing we can do to fix it

  5. MrGeorgen commented on Jul 2, 2026

    @MrGeorgen
    Author

    I also would be happy, if everyone affected by this, up votes the v8 issue (https://issues.chromium.org/issues/41480525) and / or comments on it.

  6. barjin commented on Jul 7, 2026

    @barjin
    Member

    The safest fix would be to not expose the Cheerio api directly, but instead wrap every cheerio function which returns a string with the Buffer-workaround

    As @MrGeorgen you are pointing out, this sounds like it will bring more problems in the long run. I am still convinced this is a Cheerio / V8 issue, and patching this in Crawlee would cause confusion for users.

    We appreciate you bringing this up. Since this is the first time we're getting a similar issue, it seems that not that many Crawlee users are dealing with this currently. I will close this issue as wontfix, as I don't think we can do much regarding this in the Crawlee codebase.

    Thank you anyway!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    t-toolingIssues with this label are in the ownership of the tooling team.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions