Skip to content

Issue links wait on redundant serial GraphQL queries: render cached content immediately and cold content after one fetch #8979

Description

Summary

For Alex Ross (@alexr00): opening an ordinary GitHub issue link can take seconds because the issue-opening path performs redundant model fetches and sequential freshness checks before showing usable content.

I traced this in the running extension with dbgjs, measured cached and uncached cases, and tested a reversible in-memory hotfix:

Scenario / measurement Original implementation Final in-memory prototype
Cached issue: extension-side initialization at least 1,316 ms (data barrier alone) 3 ms (opening handler)
Cached issue: populated editor-tab title after dispatching the original chat link click 1,535 ms 182 ms
Uncached issue: opening handler through initial content message 2,532 ms 624 ms

These are individual observed runs, not a controlled benchmark. The uncached tests used different issues and network conditions can vary. The request graph, rather than the percentage improvement, is the strongest evidence.

Proposed target: no network dependency for displaying an already-cached issue; at most one required issue-fetch round trip for uncached basic content, assuming repository/authentication context is ready. Create/reveal a loading editor immediately, then render content and enrich it progressively.

Related: microsoft/vscode-pull-request-github#1037 describes general slow description-webview loading. This report isolates the current issue-link path, its request sequence, and a tested way to remove the blocking dependencies. It is not the large-PR/file-count problem in microsoft/vscode-pull-request-github#8848.

Environment and scope

  • Investigation: 2026-09-22, Windows, VS Code Insiders.
  • Installed extension: GitHub.vscode-pull-request-github 0.167.2026092104.
  • githubPullRequests.openPullLinks was enabled. Despite the setting name, the registered opener handles issue links too.
  • Original entry point: a GitHub issue rich link in VS Code chat, targeting microsoft/vscode#337276.
  • Workspace repository differed from the linked issue's repository. Repository/authentication context was already warm during the measured uncached cases.
  • Measured with global dbgjs 0.1.0, reported commit 76654ea4ff0c3f3884e051469da1f8580ee581b2, dirty build.
  • This report is about issues. The prototype explicitly left pull-request behavior on its original path.
  • The prototype changed runtime methods only. It did not modify extension files, VS Code settings, or GitHub data.

Reproduction

  1. Use an authenticated GitHub Pull Requests extension and enable:

    {
      "githubPullRequests.openPullLinks": true
    }
  2. Click an https://github.com/<owner>/<repo>/issues/<number> link in chat so it opens the extension's issue editor, not a browser.

  3. Observe both:

    • the delay before the issue editor is created/revealed;
    • the delay before the actual issue content is initialized.
  4. For a warm test, close the issue editor and reopen the same issue without reloading the extension host.

  5. For an uncached-model test, choose a different issue whose model is absent from GitHubRepository.getExistingIssueModel(number). Do not infer that it is cached just because the chat chip already has a title.

  6. Instrument the opener, model resolution, panel initialization, and GraphQL operation names. Keep authentication/repository setup separate from issue-model cache state.

Examples used:

Measurement boundaries: warm UI tests dispatched click() on the actual chat anchor and observed editor-tab DOM changes. The uncached tests verified a model-cache miss and directly invoked the same registered openExternalUri handler. The latter excludes renderer dispatch and final webview paint. It is not a physical-click-to-paint measurement. No caches were deleted to manufacture the misses.

Measured request graph: uncached issue

openExternalUri
  |
  +-- resolveIssue(..., withComments=true, useCache=true)
  |     |
  |     +-- IssueWithComments                 597 ms
  |     |
  |     +-- LatestIssueUpdates                643 ms
  |
  |   Initial resolution completes ~1,243 ms after invocation.
  |   Only now can createOrShow run.
  |
  +-- IssueOverviewPanel.createOrShow
        |
        +-- updateItem -> Promise.all(...)
              |
              +-- resolveIssue(...default arguments...)
              |     +-- Issue                643 ms
              |     +-- LatestIssueUpdates   641 ms
              |
              +-- IssueTimelineEvents        559 ms  [parallel branch]
              |
              +-- access/canEdit/users       ~0 ms   [cached in this run]
        |
        +-- pr.initialize at 2,532 ms

That is five GraphQL operations, with four on the sequential critical path:

597 + 643 + max(559, 643 + 641) = 2,524 ms

The complete handler took 2,532 ms including orchestration/instrumentation overhead.

The first 1.24 seconds are particularly noticeable: the opener has not yet reached editor creation, so the click can look unresponsive.

Measured request graph: cached issue

The initial resolution hit the model cache and completed in approximately 1 ms. However, panel initialization still refetched the issue:

cached model lookup                         ~1 ms

panel initialization:
  Issue                                    618 ms
  LatestIssueUpdates                       696 ms [after Issue]
  IssueTimelineEvents                      832 ms [in parallel]

data-loading barrier                     1,316 ms

Permissions, assignable users, current user, and canEdit each completed in approximately 1 ms in this run. They were not the measured bottleneck, though they should not become a barrier in a colder environment.

Another warm UI run showed the tab after 95 ms but its populated title only after 1,535 ms. Fast tab creation is therefore not sufficient evidence that issue content loads quickly.

Source-level explanation

The links below are pinned to public main commit 596a9bbd4a36c482cbe1faa862fa8e4ec5a8a345, inspected after the live investigation. The relevant sequence matches the installed bundle; this is not a claim that this public commit is the exact extension-build commit.

  1. The opener awaits a fully resolved issue before creating the editor.
    externalUriOpener.ts:63 calls resolveIssue(owner, repo, number, true, true) before IssueOverviewPanel.createOrShow.

  2. The cache shortcut is explicit, not automatic.
    githubRepository.ts:1371 only returns the existing model when useCache is true. folderRepositoryManager.ts:2504 defaults both withComments and useCache to false.

  3. Fetching an issue also awaits a second query.
    After the issue response is parsed, githubRepository.ts:1402 awaits issue.getLastUpdateTime(...) before returning. issueModel.ts:429 performs the update-check query.

  4. The panel receives a model, but resolves it again without the cache flag.
    issueOverview.ts:308 calls resolveIssue(...) again, in a Promise.all with the timeline and other prerequisites. It does not post pr.initialize until the whole group and context preparation complete.

  5. The GraphQL client does not mask redundant requests with a query cache.
    credentials.ts:694 sets query fetchPolicy: 'no-cache'. This is separate from the extension's issue-model cache and was also confirmed on the live client.

  6. There is already a partial-update protocol.
    context.tsx:533 replaces state in setPR, while updatePR merges { ...this.pr, ...pr }. context.tsx:560 routes pr.initialize to replacement and pr.update to merging.

What the in-memory prototype changed

This was an experiment proving that the expensive dependencies can be removed from first display, not a proposed production monkey patch.

A. Separate fetching a displayable model from freshness checking

The opening-specific loader reused the original issue-loading implementation except for its final awaited freshness check:

 const issue = createOrUpdateIssueModel(await parseGraphQLIssue(response, repository));
-await issue.getLastUpdateTime(new Date(issue.item.updatedAt));
 return issue;

The normal loader remained unchanged for other callers. A freshness check ran after the initial rendering.

B. Reuse the model passed to the panel

For opening, rather than explicit refresh:

 const [issue, timeline, access, canEdit, users, currentUser] =
   await Promise.all([
-    manager.resolveIssue(owner, repo, number),
-    model.getIssueTimelineEvents(),
+    model,
+    model.timelineEvents ?? [],
     /* unchanged access/user lookups */
   ]);

The missing timeline request started concurrently, but no longer blocked initial content.

C. Merge background results instead of reinitializing

panel._postMessage({
  command: 'pr.update',
  pullrequest: { events: processedTimeline }
});

If a background freshness check detected changes, the prototype refreshed the issue with pr.update as well, rather than replacing all webview state.

It included generation/disposal guards and explicit background error reporting. Pull-request opening and manual issue refresh stayed on their original paths.

Validation of the final prototype

  • Warm original-link test: extension initialization completed in 3 ms; populated tab title appeared in 182 ms.
  • Cold model-cache miss: issue model became available after 610 ms; initial content was sent at approximately 621 ms; the awaited handler returned after 624 ms.
  • At that point the model had no loaded timeline. The timeline arrived approximately 525 ms later, and the freshness check completed afterward. This verified progressive loading rather than simply measuring a smaller return value while secretly still blocking initialization.
  • Manual refresh was exercised and still issued IssueTimelineEvents, Issue, and LatestIssueUpdates.
  • The webview merge semantics were verified in source. This was not an exhaustive interactive test of every draft/editing widget; those need production regression coverage.

An intermediate prototype only removed the duplicate issue/freshness waits but still awaited the timeline. Its cold initialization took approximately 1.8 seconds. Moving the timeline off the critical path was necessary to obtain the final behavior.

Proposed production design: make the critical path as short as possible

1. Give immediate navigation feedback

  • Create or reveal the issue editor from the parsed identity before any remote operation. Show a loading shell, or immediately reuse an existing panel/model.
  • Do not wait for authentication, repository discovery, or the full issue response merely to acknowledge the click.
  • Keep failure behavior deliberate: cancellation should not resurrect a closed editor; inaccessible/deleted issues should show an actionable state or use the existing browser fallback as appropriate.
  • Audit local prerequisites before panel creation too. For example, createOrShow awaits ensureEmojis before creating/revealing the panel. I did not measure that as a bottleneck; it is a candidate to move behind first feedback if it can block.

2. Treat initial display and refresh as different operations

  • Initial display should accept and use an already-resolved model.
  • An uncached open should need only one basic issue fetch before displaying the title/body.
  • Keep freshness/timeline/access checks explicit rather than hidden inside a general model-resolution operation.
  • Refactor/reuse the existing loading helpers rather than duplicating getIssue logic in production.
  • Preserve callers that intentionally require fresh data, and preserve the meaning of lastCheckedForUpdatesAt when moving checks.

3. Render cached state, then revalidate in the background

  • Reuse cached title/body and cached timeline immediately, even when they may need revalidation; show refreshing state where useful.
  • Do not make every click wait for a new query just to redisplay the same model.
  • Use a single-flight request for each in-flight model/timeline/freshness operation so repeated clicks, hover prefetch, restored editors, and refresh cannot create duplicate work.
  • Scope cache/request identity to host and authentication context as well as owner/repository/number. Handle authentication changes and repository disposal.
  • Maintain explicit mutation invalidation so edits/comments/reactions/labels do not appear stale.
  • Avoid declaring background results current solely because the request completed. Reject results from an obsolete panel generation or authentication context.

4. Load secondary data progressively

The issue body should not depend on the complete timeline, assignable-user enumeration, merge-method metadata, or optional enrichment.

  • Give the timeline an explicit loading/error state. [] while loading must not misleadingly mean "this issue has no events."
  • Disable actions whose permission state is unknown until authoritative data arrives; do not optimistically grant write access for speed.
  • Load assignable users on demand when opening the picker where practical.
  • Avoid fetching PR-specific metadata on issue opening unless it is genuinely required; permissions can be modeled independently.
  • Preserve required HTML safety/processing. Only optional body/link enrichment should be deferred.
  • Apply background updates through typed partial messages that preserve draft text, editing state, scroll position, and focus.
  • Surface section-specific errors with retry while keeping already-loaded content usable. Avoid unhandled rejected background promises.

5. Reduce the remaining network work, not just move it later

  • Consider whether initial IssueWithComments is needed when the editor separately loads the timeline. Fetch only the fields needed for first display; do not fetch the same comment data twice unless justified.
  • If a lightweight viewer-permission or freshness field can be included in the basic query without materially increasing cost, prefer that over another required round trip.
  • Do not bundle an expensive full timeline into the initial query merely to make the request count look smaller; that can preserve the same user-visible delay.
  • Audit whether an immediate LatestIssueUpdates after a just-fetched issue is actually needed, and whether a second one during initialization is redundant.
  • Preserve correct polling baselines, including comment/reaction changes. Avoid an immediate refetch loop caused by comparing incompatible issue/timeline freshness timestamps.

6. Prefetch only after the demand path is efficient

Optional, bounded hover/focus or likely-next-item prefetch can hide the remaining single round trip. It should:

  • share the same model cache and in-flight request, rather than creating another data pipeline;
  • be rate-limited and cancellable where supported;
  • avoid eagerly fetching every issue in a long chat/document or taking requests away from an explicit click;
  • respect host/authentication boundaries.

The UI already having a rich-link label is not evidence that the full issue model exists in the same cache.

Tests and measurable acceptance criteria

Prefer deterministic dependency tests over a fragile network-speed threshold:

  • Warm cached open: render cached title/body without awaiting any GitHub request. Hold every refresh/timeline promise pending and assert that initial content still appears.
  • Cold open: show the editor/loading shell before the issue request resolves. After the one required issue response resolves, show basic content while timeline, update checks, and secondary metadata remain pending.
  • No duplicate initial resolution: assert that the panel does not refetch the model the opener already supplied.
  • Progressive timeline: test uncached, cached-empty, cached-nonempty, delayed, and failed timelines. Loading must be distinguishable from empty.
  • State preservation: type a comment/edit the description/change scroll while background data arrives; no draft loss, focus jump, or scroll reset.
  • Freshness correctness: cached stale issue, new/edited/deleted comments, reactions, labels, and explicit refresh eventually show authoritative state without unnecessary initial blocking.
  • Concurrency: double-click, switch between issues, close/reopen, restore a panel, and change accounts while requests are in flight; obsolete results cannot overwrite newer state.
  • Error and permission handling: slow/offline API, cancellation, rate limit, authentication/SAML failure, deleted/inaccessible issue, GitHub Enterprise, and pending write permissions.
  • Scope preservation: opening existing PRs and explicit manual refresh retain their intended semantics.

Suggested performance goals, to calibrate with the test harness rather than present as guarantees:

  • Local editor/navigation feedback within approximately 100 ms, even with artificial API latency.
  • Cached initial content within approximately 100 ms of handler entry under controlled local conditions.
  • Cold basic content after approximately one issue-fetch latency plus local rendering work, not four serial query latencies.
  • A slow secondary request must not increase time to first useful issue content.

Add phase measurements for:

click -> opener entry -> editor reveal -> initial content sent
      -> webview first useful paint -> timeline ready -> freshness complete

Record cache hit/miss, operation counts, and relevant phase durations without logging issue bodies, tokens, or private content. Use p50/p95 from repeated controlled runs with fixed latency, plus a few real-network runs. Handler completion, pr.initialize emission, populated tab title, and final paint are different measurements and should remain separate.

Limitations of this investigation

  • Authentication, repository lookup, and secondary metadata were warm in the measured traces. A cold extension activation or new account/repository can add work.
  • The original cold observer was disconnected too early, so the 1.24-second pre-editor delay is established by handler timestamps and source ordering, not a valid cold DOM-tab timestamp.
  • Query durations are wall-clock promise durations, not a breakdown of network transport versus GitHub server processing.
  • The in-memory experiment demonstrates feasibility, not a production-quality implementation or comprehensive UX validation.
  • No conclusion depends on publishing the user's full settings, private issue bodies, or credentials.

Activity

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions