Issue links wait on redundant serial GraphQL queries: render cached content immediately and cold content after one fetch
@alexr00 is already working on this.
Since Sep 22, 2026.
Assessment
This issue has not been assessed yet.
Description
Summary
For @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-github0.167.2026092104. githubPullRequests.openPullLinkswas 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 commit76654ea4ff0c3f3884e051469da1f8580ee581b2, 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
-
Use an authenticated GitHub Pull Requests extension and enable:
{ "githubPullRequests.openPullLinks": true } -
Click an
https://github.com/<owner>/<repo>/issues/<number>link in chat so it opens the extension's issue editor, not a browser. -
Observe both:
- the delay before the issue editor is created/revealed;
- the delay before the actual issue content is initialized.
-
For a warm test, close the issue editor and reopen the same issue without reloading the extension host.
-
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. -
Instrument the opener, model resolution, panel initialization, and GraphQL operation names. Keep authentication/repository setup separate from issue-model cache state.
Examples used:
- Warm: microsoft/vscode#337276.
- Original uncached trace: microsoft/vscode#337284.
- Final prototype uncached validation: microsoft/vscode#337281.
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.
-
The opener awaits a fully resolved issue before creating the editor.
externalUriOpener.ts:63callsresolveIssue(owner, repo, number, true, true)beforeIssueOverviewPanel.createOrShow. -
The cache shortcut is explicit, not automatic.
githubRepository.ts:1371only returns the existing model whenuseCacheis true.folderRepositoryManager.ts:2504defaults bothwithCommentsanduseCacheto false. -
Fetching an issue also awaits a second query.
After the issue response is parsed,githubRepository.ts:1402awaitsissue.getLastUpdateTime(...)before returning.issueModel.ts:429performs the update-check query. -
The panel receives a model, but resolves it again without the cache flag.
issueOverview.ts:308callsresolveIssue(...)again, in aPromise.allwith the timeline and other prerequisites. It does not postpr.initializeuntil the whole group and context preparation complete. -
The GraphQL client does not mask redundant requests with a query cache.
credentials.ts:694sets queryfetchPolicy: 'no-cache'. This is separate from the extension's issue-model cache and was also confirmed on the live client. -
There is already a partial-update protocol.
context.tsx:533replaces state insetPR, whileupdatePRmerges{ ...this.pr, ...pr }.context.tsx:560routespr.initializeto replacement andpr.updateto 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, andLatestIssueUpdates. - 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,
createOrShowawaitsensureEmojisbefore 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
getIssuelogic in production. - Preserve callers that intentionally require fresh data, and preserve the meaning of
lastCheckedForUpdatesAtwhen 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
IssueWithCommentsis 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
LatestIssueUpdatesafter 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.
- Dominant language
- TypeScript
- Stars
- 2.6k
- Forks
- 796
- Avg merge
- 14h 34m
- Merged PRs (30d)
- 43
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/vscode-pull-request-github
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
grrrrrrr Open~spam
Difficulty 5/5 Over a week Newbie friendliness 5/100
-
grrrrrrr Open~spam
Difficulty 5/5 Over a week Newbie friendliness 1/100
-
microsoft/vscode-pull-request-github#8969 · 1 assignee ·
All issues in microsoft/vscode-pull-request-github
Similar issues
-
VerificationGate: ATTRIBUTION quote guard never matches a normal quotation (\b around the quote) Open
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
danielmiessler/LifeOS#2234 ·
-
T: Bug
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
-
Mend: dependency security vulnerability untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 70/100