Skip to content

perf: Reduce per-request CPU and memory overhead#10598

Draft
dblythy wants to merge 4 commits into
parse-community:alphafrom
dblythy:perf/reduce-per-request-overhead
Draft

perf: Reduce per-request CPU and memory overhead#10598
dblythy wants to merge 4 commits into
parse-community:alphafrom
dblythy:perf/reduce-per-request-overhead

Conversation

@dblythy

@dblythy dblythy commented Jul 21, 2026

Copy link
Copy Markdown
Member

Issue

We do a fair bit of avoidable work on every request - Config.get rebuilds the full config 3x per request, SchemaData is rebuilt twice per request even on a warm schema cache, log payloads are built even with verbose logging off, and the username/email uniqueness checks run as two sequential DB round trips.

Approach

  • add Utils.deepClone, a faster structuredClone-compatible clone for plain JSON payloads (keeps the GHSA-9ccr-fpp6-78qf prototype poisoning protection)
  • build one Config template per app, per-request configs derive from it via prototype
  • cache SchemaData across requests, invalidated on schema changes
  • only update AppCache in loadKeys when an async key actually changed
  • skip building log payloads unless verbose logging is on
  • run the username/email uniqueness checks in parallel
  • skip guaranteed-to-throw JSON.parse calls in JSONFromQuery
  • skip the file URL scan when no allowedFileUrlDomains is configured

Benchmarked locally over real HTTP: -28% memory allocated per request, -27% GC cycles, p50 down 10-29% in every paired sustained-load run. Soak tested with 14M+ requests at ~3-4k req/s: zero errors, post-GC heap flat, no leaks.

Tasks

  • Add tests

@parse-github-assistant

Copy link
Copy Markdown

🚀 Thanks for opening this pull request! We appreciate your effort in improving the project. Please let us know once your pull request is ready for review.

Tip

  • Keep pull requests small. Large PRs will be rejected. Break complex features into smaller, incremental PRs.
  • Use Test Driven Development. Write failing tests before implementing functionality. Ensure tests pass.
  • Group code into logical blocks. Add a short comment before each block to explain its purpose.
  • We offer conceptual guidance. Coding is up to you. PRs must be merge-ready for human review.
  • Our review focuses on concept, not quality. PRs with code issues will be rejected. Use an AI agent.
  • Human review time is precious. Avoid review ping-pong. Inspect and test your AI-generated code.

Note

Please respond to review comments from AI agents just like you would to comments from a human reviewer. Let the reviewer resolve their own comments, unless they have reviewed and accepted your commit, or agreed with your explanation for why the feedback was incorrect.

Caution

Pull requests must be written using an AI agent with human supervision. Pull requests written entirely by a human will likely be rejected, because of lower code quality, higher review effort and the higher risk of introducing bugs. Please note that AI review comments on this pull request alone do not satisfy this requirement. Our CI and AI review are safeguards, not development tools. If many issues are flagged, rethink your development approach. Invest more effort in planning and design rather than using review cycles to fix low-quality code.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 786a947c-e0ac-4b75-8395-87716f757db6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…quest-overhead

# Conflicts:
#	spec/Utils.spec.js
@dblythy

dblythy commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Full benchmarking and profiling report for context - methodology, numbers, and the auditing behind each change:

Parse Server Performance Investigation

Date: 2026-07-21 · Machine: local dev (macOS, Apple Silicon, Node v22.15.0, MongoDB 8.2.6 local) · Method: repo benchmark suite (benchmark/performance.js, run on port 21337 against local mongod), plus steady-state CPU profiling of a live server under create/query load.

1. Baseline benchmark

Suite run on the compiled lib/ of branch fix/coverage-mongotransform (equivalent to current alpha + MongoTransform perf work). Values are medians after IQR outlier filtering.

Benchmark Median p95 p99
Object.save (create) 0.32 ms 0.68 ms 0.79 ms
Object.save (update) 0.30 ms 0.53 ms 0.61 ms
Object.saveAll (batch save) 0.71 ms 1.00 ms 1.07 ms
Query.get (by objectId) 0.27 ms 0.57 ms 0.67 ms
Query.find (simple query) 0.31 ms 0.50 ms 0.58 ms
Query.find ($relatedTo relation) 1.01 ms 1.57 ms 1.82 ms
User.signUp 48.41 ms 49.84 ms 50.46 ms
User.login 48.73 ms 49.63 ms 50.19 ms
Query.include (parallel pointers, 100 ms simulated DB latency) 203.89 ms 205.19 ms 206.35 ms
Query.include (nested pointers, 100 ms simulated DB latency) 407.84 ms 410.00 ms 411.52 ms
Query.find (large result, GC time/op) 10.56 ms 15.26 ms 16.76 ms
Query.find (concurrent, GC time/op) 87.46 ms 105.99 ms 131.19 ms
Object.save (nested data, denylist scan) 0.20 ms 0.31 ms 0.35 ms
Query $regex 0.44 ms 0.55 ms 0.59 ms
LiveQuery $regex 0.44 ms 0.63 ms 0.70 ms

Interpretation:

  • Simple CRUD is already sub-millisecond on localhost; there is no single dominant bottleneck, but there is a broad layer of per-request CPU overhead (see §2).
  • User.signUp / User.login (~48 ms) are dominated by bcrypt cost-10 hashing. The native @node-rs/bcrypt is installed and in use; this cost is an intentional security property, not a defect.
  • The include benchmarks match their designed round-trip counts under artificial 100 ms DB latency (2 round trips for same-level includes, 4 for the 3-level nested chain). Latency here is DB-bound, not CPU-bound.

2. Steady-state CPU profile (where request CPU actually goes)

Profiled 3,000 create + 3,000 query requests against a live in-process server, profiler started after warmup. Process busy time was ~1.26 s; the load driver (Parse JS SDK) and the MongoDB driver run in the same process, so "server-only" CPU is smaller than busy time. Top parse-server frames by self-time:

Cost (self-time) Share of busy CPU What
~67 ms ~5.3 % Config.get - rebuilds a full Config object (~80 option keys) plus a new DatabaseController, called 3× per request (CORS middleware, auth middleware, SchemaController constructor)
~60 ms ~4.7 % structuredClone - RestWrite deep-clones the request body and query on every write; structuredClone has ~0.64 µs/call fixed overhead vs ~0.06 µs for a manual clone of the same small object (measured)
~31 ms ~2.5 % ClassesRouter.JSONFromQuery - JSON.parse inside try/catch throws for every non-JSON query value (e.g. order=-createdAt costs ~3.4 µs/exception, measured)
~29 ms ~2.3 % SchemaData rebuild - a new SchemaController + SchemaData (one Object.defineProperty closure pair per class) is built per request even when the schema cache is warm; the constructor-built SchemaData is then immediately discarded and rebuilt by reloadData(), i.e. built twice per request

Additional findings from code inspection (smaller or latent):

  • SchemaCache.get(className) copied the entire class array per call just to find one element.
  • FileUrlValidator recursively walks the entire payload on every create/update even when fileUpload.allowedFileUrlDomains is not configured (early-out only happened after a File field was found).
  • Latent quirk (no longer reachable after the fix): SchemaController's constructor built SchemaData with this.protectedFields before assigning it, so the constructor-built instance always had empty protectedFields; it was harmless only because load() immediately rebuilds it via reloadData().
  • Not worth changing: trigger lookups, master-key auth path, idempotency middleware (all confirmed cheap); GC-pressure numbers are dominated by MongoDB cursor batches, already addressed by earlier work.

3. Changes implemented (branch perf/reduce-per-request-overhead, off alpha)

  1. Utils.deepClone - structuredClone-compatible fast clone (plain objects/arrays cloned manually; __proto__ keys copied as own properties to keep the GHSA-9ccr-fpp6-78qf prototype-poisoning protection; aliases/cycles preserved; Dates/Buffers/class instances and non-cloneables delegated to structuredClone). Adopted at the hot call sites: RestWrite (body/query/ACL/login clones), DatabaseController.update, SchemaData CLP clone.
  2. Config.get 3× → 1× per request - the CORS middleware and the SchemaController constructor now read the raw AppCache entry (static option values) instead of building a full Config + DatabaseController; only the auth middleware still builds the real per-request Config.
  3. SchemaData cross-request cache - derived schema data is cached in a WeakMap keyed on the identity of the array stored in SchemaCache; every schema change flows through SchemaCache.put() with a new array, which invalidates naturally. setPermissions (which mutates a cached element in place) now re-puts the array explicitly. Snapshot mismatches (concurrent schema change mid-request) fall back to an uncached build.
  4. SchemaCache.get no longer copies the array; added SchemaCache.raw() for identity-keyed caching.
  5. JSONFromQuery - skips JSON.parse for strings that provably cannot be JSON (first-character check), eliminating the per-request exception cost; parsing behavior is otherwise identical.
  6. FileUrlValidator - early-out before the recursive payload walk when no domain restriction is configured.

4. Why signUp/login is ~50 ms (and why that is by design)

Measured directly with the installed native @node-rs/bcrypt: hash at cost factor 10 takes ~47–69 ms/op on this machine (varies with background load), verify ~47–55 ms/op. Parse Server hardcodes bcrypt cost 10 (src/password.js), the OWASP-recommended minimum work factor: signUp pays one hash, login one verify, and the entire rest of the request accounts for ~1 ms. This is intentional brute-force resistance, not server overhead. Mitigations if auth latency matters: reuse session tokens (login is a one-time cost; session validation is a cheap lookup), scale horizontally (bcrypt is stateless CPU), or - as a possible feature - make the cost factor configurable, though lowering the default below 10 would be a security regression. The native library is confirmed in use; the pure-JS fallback (2–3× slower) is not the issue.

5. Wall-clock A/B: inconclusive due to machine noise

A full-suite A/B (alpha build vs perf build, sequential ~4.5 min runs) produced contradictory swings well above the expected effect size: e.g. Object.save (update) +52 % and Query.get +37 % on a branch that strictly removes work from those paths, Query.find (concurrent, GC) −72 %, LiveQuery $regex +75 %. Cross-checking run-to-run drift: the same code measured 0.22 ms vs 0.32 ms for create in two runs ~30 min apart. This machine hosts other active dev processes; sub-millisecond wall-clock medians drift by tens of percent between runs, swamping a ~10 % CPU effect.

Conclusion: wall-clock medians on this machine cannot resolve the change; the honest metric for these optimizations is server-process CPU time per request (robust against background load), measured with alternating fresh-process runs of both builds (§6).

6. CPU-per-request A/B (alternating, 4 rounds × both builds)

Harness: fresh Node process per run, fresh database, in-process server; 300-op warmup, then 6,000 mixed REST ops (create / find / get / update) measured with process.cpuUsage(). Builds alternate alpha → perf within each round, so slow machine-load drift affects both sides of a pair equally.

Round alpha (µs CPU/op) perf (µs CPU/op) Δ
1 722.8 604.4 −16.4 %
2 680.3 322.4 −52.6 % (machine load dropped mid-round; excluded)
3 357.6 304.4 −14.9 %
4 286.4 254.6 −11.1 %

Absolute values fall across rounds as unrelated background load decayed - which is exactly why the paired, alternating design matters. Consistent result: 11–16 % less CPU per request, matching the profile-predicted magnitude (§2). The measurement includes the in-process Parse JS SDK client and MongoDB driver CPU (untouched by these changes), so the reduction in server-side CPU alone is larger than these percentages. Noise-floor check: per-round minimums 286.4 → 254.6 µs/op (−11 %); minimum wall time 0.283 → 0.249 ms/op (−12 %).

CPU per request converts directly to capacity: at saturation a node serves ~12–18 % more requests, and queueing latency under load drops accordingly. DB-bound benchmarks (includes under simulated latency) are unaffected, as expected.

7. Memory profiling (allocation sampling + GC + retained heap)

Using V8's sampling heap profiler with includeObjectsCollectedByMajorGC/MinorGC (without those flags it reports only live objects - an earlier pass mismeasured by ~100×).

Key discovery about all prior measurement: ParseServer replaces the Parse SDK's REST controller with an in-process bridge (ParseServerRESTController), so any load driver that requires the same parse/node module as the server - including the repo's own benchmark/performance.js - bypasses Express, every middleware, and HTTP entirely. It even bypasses the server-state check (start() never being called goes unnoticed). All headline numbers below therefore come from a corrected two-process harness: load client in a separate process over real HTTP; profilers attached only to the server process.

Server-process results over real HTTP (mixed create/find/update, warm server, per request):

Metric alpha notes
CPU ~1,430 µs roughly 2× the internal-path numbers; middlewares + HTTP included
Allocations 155 KB per request measured on the wave-1 build before the Config work
GC ~39 ms per 1,000 requests
Retained-heap growth none attributable to the server earlier ~0.75 KB/op growth was the in-process SDK client's object-state map

Top server-side allocators: Express router internals ~21 KB/op (inherent); Config.get cluster ~12.8 KB/op (~8%) - the ~80-key copy loop, constructor, and loadKeys; handleParseHeaders ~5.9 KB/op; DatabaseController.loadSchema ~1.6 KB/op.

8. Second-wave optimizations (memory-driven, bolder)

  1. Config prototype templates - Config.get now builds one fully-populated template per app (cached in a WeakMap keyed on the AppCache entry) and per-request Configs are Object.create(template) with only request-specific own properties (database, mount, bound generators). Audited before implementing: no enumeration/spread/delete of Config instances anywhere in src/; per-request assignments shadow safely. One found-and-fixed hazard: Config.put can store a Config instance back into AppCache (e.g. addRateLimit), so the template builder uses for..in to include inherited keys - caught by RateLimit/Middlewares specs, now green.
  2. loadKeys no longer churns AppCache - it previously re-put a fresh ~80-key copy of the app config into AppCache on every request; now it returns early unless an async key (publicServerURL as function) actually resolved to a changed value.
  3. Lazy request logging - PromiseRouter only builds the log payload (URL masking + body copy) when verbose logging is enabled (LoggerController.verboseEnabled); custom injected loggers keep the old always-on behavior.
  4. Parallel user validation (from the N+1 sweep) - _validateUserName() and _validateEmail() were sequential DB round trips on every _User create/update; now Promise.allSettled with username-error priority preserved. Saves one full DB round trip of latency on every signup/user update - this one improves latency even when the DB is the bottleneck.

N+1/sequential-await sweep also flagged (not changed): a duplicate findUsersWithAuthData lookup on social-login paths that is deliberate security defense-in-depth; a sequential per-subrequest rate-limit loop in batch.js (only matters with custom rate limiters); serial subquery replacement passes in RestQuery for multi-$inQuery queries (rare shape); per-object File-field expansion that is only async with custom file adapters.

9. Final A/B over real HTTP (server process only, alternating 3 rounds)

Metric (per request) alpha perf branch Δ
Allocated memory 197.5 KB 141.3 KB −28 %
GC cycles (per 4,500 ops) 85–89 63 −27 %
GC time (ms/1,000 ops) 12.8–19 8.2–11.9 −8 % to −55 %
Server CPU (µs, paired rounds) 684 / 768 / 635 663 / 560 / 618 perf wins all 3 pairs (−3 %, −27 %, −3 %)

Allocation volume and GC-cycle count are the most noise-robust metrics here, and both show a consistent ~27–28 % reduction. After the changes, the biggest remaining allocators are Express's own routing internals (~21 KB/op) and handleParseHeaders (5.6 KB/op); the Config.get cluster no longer appears in the top-20. The parallel username/email validation additionally removes one DB round trip of latency from every signup/user update (not visible in these CRUD-mix numbers).

10. Soak testing (sustained load, realistic mix)

Rig: separate-process HTTP client, 16 concurrent virtual users over keep-alive, realistic op mix (40 % session-authenticated queries, 15 % gets, 20 % creates, 19 % updates, 4 % batches, deletes, ~1 % logins), per-30 s latency windows; server samples event-loop delay percentiles, RSS/heap, GC, CPU per 10 s, plus forced-GC heap checkpoints for leak detection. Peak observed sustained rate: ~2,600 req/s on one server process.

Pilot (2 × 6 min, ~547k requests total, unindexed query) - two findings:

  1. Throughput collapsed ~5× within 6 minutes on both builds (~1,950 → ~360 req/s). The mix's query (bucket = k, order=-createdAt) had no index while creates grew the collection ~400 docs/s - every query degraded into a growing collection scan + sort. No micro-benchmark shows this; it dominated everything else. Index design is the operator's responsibility (and MongoDB's profiler/Atlas advisor cover detection) - the takeaway here is about benchmark realism and deployment behavior: an unindexed hot query works fine at launch and collapses as data grows, and while present it outweighs any server-side optimization by orders of magnitude. The index was added to the rig so the A/B compares server builds, not scan costs.
  2. No memory leak in either build: post-GC heap flat at ~56.4 MB after ~270k requests each; RSS stable. The perf build's GC advantage held under saturation (−26 % GC time and count).

Pilot 2 (indexed, 2 × 6 min, 2.39M requests, both builds at ELU 0.992 = saturation):

Metric alpha perf Δ
Requests completed (6 min) 1,041,030 1,345,538 +29 %
Median window throughput 3,207 req/s 4,160 req/s +30 %
p50 / p95 latency 3.9 / 10.8 ms 3.1 / 5.7 ms −20 % / −47 %
p99 / p99.9 52.1 / 68.0 ms 50.7 / 64.7 ms −3 % / −5 %
Worst request 969 ms 293 ms −70 %
Post-GC heap after run 56.8 MB 56.8 MB flat, no leak

Caveats being resolved by the running campaign: alpha ran first each time (cold-cache order bias), and the perf run's final windows dipped (possible time decay). Campaign: 3 interleaved rounds of 10 min per build, then a 2-hour soak on the perf build (~30M requests total).

Interleaved campaign (3 rounds × 10 min per build, ~11.8M requests):

Per-round throughput swung ±30–50 % with background dev-machine contention (one perf run and one alpha run each took a visible contention burst), so single-round throughput deltas are not trustworthy. What is consistent:

  • p50 latency: perf build won all four paired runs (−10 % to −29 %) - the median is robust to contention bursts and matches the controlled CPU/allocation results.
  • Aggregate throughput: +13.4 % for the perf build (6.29M vs 5.55M requests in equal wall time).
  • Zero errors and zero leak in all 8 soak runs: post-GC heap 56.6–56.8 MB regardless of build or duration.
  • A suspected "perf build degrades late in a run" pattern (two occurrences) was disproven by round 2's clean 10-minute perf run at ~4,300 req/s - the dips correlated with machine contention, not the build.

2-hour soak on the perf build (~18M requests) - in progress.

11. Verification status

  • Baseline benchmark recorded (above)
  • Changes compiled into lib/ and confirmed present
  • Wall-clock A/B run - inconclusive (§5); superseded by CPU-per-request A/B (§6)
  • Jasmine specs on MongoDB, each file in isolation: Utils (incl. 8 new deepClone tests), Middlewares 37/37, vulnerabilities 158 executed, ProtectedFields, Schema 61/61, PointerPermissions, RestQuery 23/23, CloudCode 223/224, ParseUser 168/170 - all 0 failures (pending counts are pre-existing xit skips)
  • CPU-per-request A/B (wave 1, internal path) - 11–16 % CPU reduction (§6)
  • HTTP two-process A/B incl. wave-2 changes - −28 % allocations, −27 % GC cycles (§9)
  • Wave-2 specs: RateLimit, Middlewares, ParseUser, vulnerabilities, CloudCode - green after the Config.put template fix
  • Same specs on PostgreSQL - deliberately skipped (Docker Desktop daemon broken on this machine; per decision, run in CI with the PR - changed code is in the shared controller layer, no adapter-specific code touched)
  • Lint (npm run lint) - clean

Numbers in §2 are CPU self-times under a synthetic localhost load; the expected end-to-end win is a 10–20 % reduction in server-side CPU per simple request (which translates to latency mainly under CPU saturation), not a change to DB-bound latency. The A/B run will quantify it.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.63866% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.55%. Comparing base (5838c07) to head (c341e27).
⚠️ Report is 1 commits behind head on alpha.

Files with missing lines Patch % Lines
src/Controllers/SchemaController.js 92.00% 2 Missing ⚠️
src/ParseServer.ts 50.00% 0 Missing and 1 partial ⚠️
src/Routers/ClassesRouter.js 92.30% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            alpha   #10598   +/-   ##
=======================================
  Coverage   93.55%   93.55%           
=======================================
  Files         192      192           
  Lines       16832    16919   +87     
  Branches      248      249    +1     
=======================================
+ Hits        15747    15829   +82     
- Misses       1064     1068    +4     
- Partials       21       22    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dblythy

dblythy commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@mtrezza wondering if you think this exploration is worth continuing

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.

1 participant