Skip to content

perf: cache deviceOf() and isBot() per request via WeakMap#1088

Open
hugo-ccabral wants to merge 1 commit intomainfrom
perf/cache-useragent-per-request
Open

perf: cache deviceOf() and isBot() per request via WeakMap#1088
hugo-ccabral wants to merge 1 commit intomainfrom
perf/cache-useragent-per-request

Conversation

@hugo-ccabral
Copy link
Contributor

@hugo-ccabral hugo-ccabral commented Feb 25, 2026

Summary

  • WeakMap<Request, Device> cache for deviceOf() — same Request object returns cached result across multiple loaders
  • WeakMap<Request, boolean> cache for isBot() — same pattern
  • Skip new URL() in deviceOf() when cf-device-type header is present (Cloudflare fast path). Previously always constructed a full URL object even when the header was available
  • Use URLSearchParams instead of new URL() as last resort for deviceHint query param — avoids full URL parsing when only the query string is needed
  • WeakMap ensures automatic cleanup when Request objects are GC'd (no manual eviction needed)

Context

CPU profile showed ua-parser-js at 0.61% of total CPU (1,044 hits). deviceOf() and isBot() are called via lazy getters in fnContextFromHttpContext(), but a new FnContext is created for each loader (via applyProps), so the lazy cache doesn't persist across loaders for the same request. The WeakMap cache fixes this.

Memory profile showed isBot() at 3 MB (0.3%) and deviceOf() at 2.3 MB (0.3%) in allocation timeline.

Test plan

  • Run existing deco test suite
  • Verify device detection still works correctly for mobile, tablet, and desktop
  • Verify bot detection still works for Cloudflare bots, known bots, and UA-detected bots
  • Verify deviceHint query parameter fallback still works

Summary by cubic

Caches deviceOf() and isBot() per Request using WeakMap to avoid repeated UA parsing and speed up loaders. Also skips full URL parsing by using the cf-device-type fast path and URLSearchParams for the deviceHint fallback.

  • Refactors
    • Cache results with WeakMap<Request, Device> and WeakMap<Request, boolean>.
    • Use cf-device-type header when present; no new URL() needed.
    • Fallback reads deviceHint via URLSearchParams only if the query exists.
    • WeakMap cleans up automatically; no manual eviction.

Written for commit d074073. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Enhanced device detection and bot detection logic with improved type safety and streamlined processing.

- Add WeakMap<Request, Device> cache for deviceOf() and WeakMap<Request, boolean> for isBot() - Avoid new URL() construction in deviceOf() when cf-device-type header is present (fast path) - Only parse URL search params as last resort (URLSearchParams instead of full URL object) - WeakMap ensures automatic cleanup when Request objects are GC'd - CPU profile showed ua-parser-js at 0.61 percent, called repeatedly for the same request across different loaders

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions
Copy link
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.164.1 update
  • 🎉 for Minor 1.165.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Refactored deviceOf and isBot functions in utils/userAgent.ts to implement request-level caching via WeakMaps, add explicit return type annotations, and introduce multi-stage fallback resolution for device detection with improved bot detection logic using header prioritization and UAParser integration.

Changes

Cohort / File(s) Summary
User Agent Detection Optimization
utils/userAgent.ts
Introduced WeakMap-based request-level caching for device and bot detection. Refactored deviceOf to implement cascading resolution: cf-device-type header → UAParser user-agent inference → deviceHint query parameter → "desktop" default, with normalization before caching. Refactored isBot to prioritize cf-verified-bot header, check known bot patterns, then fall back to UAParser detection. Added explicit return type annotations (Device and boolean) to both functions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰✨ A rabbit hops through the caching lanes,
Where bot detection bravely reigns,
Device types cached, fallbacks aligned,
Headers and hints in harmony designed! 🎯

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding per-request caching via WeakMap for deviceOf() and isBot() functions, directly matching the PR objectives and file modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/cache-useragent-per-request

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
utils/userAgent.ts (2)

24-49: Multi-stage fallback logic is sound.

One subtlety worth documenting: Cloudflare's cf-device-type can return "desktop", which is not a key in ideviceToDevice. It still resolves correctly thanks to the ?? "desktop" fallback on line 47, but it relies on the default rather than an explicit mapping. Consider adding desktop: "desktop" to ideviceToDevice for clarity.

Proposed change
 const ideviceToDevice: Record<string, Device> = {
   mobile: "mobile",
   tablet: "tablet",
+  desktop: "desktop",
   console: "desktop",
   smarttv: "desktop",
   wearable: "desktop",
   embedded: "desktop",
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/userAgent.ts` around lines 24 - 49, The cf-device-type value "desktop"
can bypass the ideviceToDevice mapping and rely on the final "?? 'desktop'"
fallback; update ideviceToDevice to include an explicit mapping for "desktop":
"desktop" (so ideviceToDevice["desktop"]="desktop") and optionally add a brief
code comment near the ideviceToDevice declaration explaining why cfDeviceHint
(cf-device-type) needs this explicit mapping; no other logic changes in the
cfDeviceHint/UAParser/device fallback or deviceCache usage are required.

62-77: UABotParser.setUA(ua) mutates a module-level singleton — safe today but fragile.

The chained synchronous call UABotParser.setUA(ua).getBrowser() is safe in a single-threaded JS runtime because no interleaving can occur within one expression. However, unlike deviceOf (which creates a fresh new UAParser(ua) on line 32), this pattern silently breaks if an await is ever inserted between setUA and getBrowser. Consider either: (a) adding a comment noting the synchronous-chain requirement, or (b) creating a new UAParser(Bots) instance per cache-miss to match the pattern used in deviceOf.

Also, extracting the user-agent header once and reusing it across the KNOWN_BOTS check and the UAParser path would avoid redundant headers.get calls:

Proposed refactor
   let result = false;
 
+  const ua = req.headers.get("user-agent") || "";
   const fromCloudFlare = req.headers.get("cf-verified-bot");
   if (fromCloudFlare === "true") {
     result = true;
   } else if (
-    KNOWN_BOTS.some((bot) => req.headers.get("user-agent")?.includes(bot))
+    KNOWN_BOTS.some((bot) => ua.includes(bot))
   ) {
     result = true;
   } else {
-    const ua = req.headers.get("user-agent") || "";
-    const browser = UABotParser.setUA(ua).getBrowser() as unknown as {
+    // setUA mutates the singleton — keep this a single synchronous chain
+    const browser = new UAParser(ua, Bots).getBrowser() as unknown as {
       type: string;
     };
     result = browser.type === "bot";
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/userAgent.ts` around lines 62 - 77, Extract the user-agent once into a
local variable (e.g., const ua = req.headers.get("user-agent") || "") and use
that for the KNOWN_BOTS check instead of calling req.headers.get repeatedly;
replace the singleton-mutating call UABotParser.setUA(ua).getBrowser() with
creating a fresh parser instance (new UAParser(ua).getBrowser()) like deviceOf
does, and set result = (browser.type === "bot"); this removes the fragile
module-level mutation and avoids the synchronous-chain hazard while keeping
KNOWN_BOTS, UABotParser.setUA, getBrowser, deviceOf and UAParser references to
locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@utils/userAgent.ts`:
- Around line 24-49: The cf-device-type value "desktop" can bypass the
ideviceToDevice mapping and rely on the final "?? 'desktop'" fallback; update
ideviceToDevice to include an explicit mapping for "desktop": "desktop" (so
ideviceToDevice["desktop"]="desktop") and optionally add a brief code comment
near the ideviceToDevice declaration explaining why cfDeviceHint
(cf-device-type) needs this explicit mapping; no other logic changes in the
cfDeviceHint/UAParser/device fallback or deviceCache usage are required.
- Around line 62-77: Extract the user-agent once into a local variable (e.g.,
const ua = req.headers.get("user-agent") || "") and use that for the KNOWN_BOTS
check instead of calling req.headers.get repeatedly; replace the
singleton-mutating call UABotParser.setUA(ua).getBrowser() with creating a fresh
parser instance (new UAParser(ua).getBrowser()) like deviceOf does, and set
result = (browser.type === "bot"); this removes the fragile module-level
mutation and avoids the synchronous-chain hazard while keeping KNOWN_BOTS,
UABotParser.setUA, getBrowser, deviceOf and UAParser references to locate the
change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e02918 and d074073.

📒 Files selected for processing (1)
  • utils/userAgent.ts

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="utils/userAgent.ts">

<violation number="1" location="utils/userAgent.ts:39">
P2: URLSearchParams does not strip hash fragments, meaning `deviceHint` may incorrectly include `#hash`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if (!device) {
const qIdx = request.url.indexOf("?");
if (qIdx !== -1) {
const params = new URLSearchParams(request.url.slice(qIdx));
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: URLSearchParams does not strip hash fragments, meaning deviceHint may incorrectly include #hash.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At utils/userAgent.ts, line 39:

<comment>URLSearchParams does not strip hash fragments, meaning `deviceHint` may incorrectly include `#hash`.</comment>

<file context>
@@ -15,41 +15,67 @@ const ideviceToDevice: Record<string, Device> = {
+    if (!device) {
+      const qIdx = request.url.indexOf("?");
+      if (qIdx !== -1) {
+        const params = new URLSearchParams(request.url.slice(qIdx));
+        device = params.get("deviceHint") || "desktop";
+      } else {
</file context>
Suggested change
const params = new URLSearchParams(request.url.slice(qIdx));
const hashIdx = request.url.indexOf("#", qIdx);
const params = new URLSearchParams(
request.url.slice(qIdx, hashIdx !== -1 ? hashIdx : undefined)
);
Fix with Cubic

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