The @forge/bridge import that kills your resolver, and why nothing warns you
Mihai Perdum
Author
13 min readAugust 4, 2026
Key takeaways
@forge/bridge throws at IMPORT time, not call time. A handler that imports a poisoned module and never calls it still fails, which I measured as HTTP 424 on a live site.
The error string changed in 5.15.1 (13 April 2026): window.__bridge became globalThis.__bridge, so ReferenceError: window is not defined became BridgeAPIError. The community threads on this all predate the change and use the old string, so searching the error you actually see will not find them.
The stack trace names the innocent function and never names the file containing the bad import.
forge lint returned No issues found and forge deploy succeeded on code that cannot run.
The bundle went from 2,367 to 241,603 bytes. Patching sideEffects:false into @forge/bridge collapses it back to 2,620, but the package cannot honestly declare that, because it really does throw at import.
In March a developer posted to the Atlassian developer community with a Confluence Forge app that had been running for over a year. Their words: "my logs are overflowing with the following error ReferenceError: window is not defined". Nothing had changed in the React app or the Node backend. They had only edited manifest.yml.
Five days after the thread was answered, Atlassian shipped a patch that changed the error message. So if you hit this bug today you get a completely different string, and searching for it will not find that thread, or the five others I turned up whose titles carry the same old wording.
The cause is one line, and it is probably in a file you would never look at. This is what actually happens, measured end to end on a live Atlassian site rather than reasoned about: the four-way experiment that isolates it, the bundle numbers, the reason your bundler cannot save you, and the fix.
Note
Everything below was measured on 4 August 2026 against @forge/bridge 6.2.0 (published 3 August 2026), Forge CLI 13.3.0, @forge/bundler 7.1.0 and webpack 5.99.9. The live runs are against a real Jira Cloud site. Version numbers matter more than usual in this piece, because the symptom itself is version-dependent.
The one-line cause
Forge apps have two halves that feel like one codebase. @forge/bridge is the frontend half: it is how Custom UI talks to your resolver. @forge/api and your resolver code are the backend half, running in a Node sandbox with no window and no browser.
So you write a helper both halves need:
js
1// src/shared.js2import{ events }from'@forge/bridge';34exportfunctionformatKey(key){5returnString(key).trim().toUpperCase();6}78// only ever called from the browser9exportfunctionsubscribeToIssue(cb){10return events.on('JIRA_ISSUE_CHANGED', cb);11}
This is a completely ordinary file. formatKey is a pure string function with no browser dependency at all. subscribeToIssue is frontend-only and your resolver never touches it.
Now the resolver imports the safe half, and only the safe half:
That function is dead. Not slow, not degraded. Dead.
Proving it, four ways
Reasoning about bundlers is how people end up confidently wrong, so I built the smallest thing that could tell me the truth: one Forge app with four web triggers, deployed to a real site. Web triggers are ideal here because you invoke them with curl and get a synchronous answer, with no frontend build and no waiting for a product event.
The four variants differ only in their imports:
variant
shared module
handler calls the helper?
clean
no bridge import
yes
poisoned
unused bridge import
yes
untouched
unused bridge import
no
fixed
module split in two
yes
5 rows × 3 columnsHeader row enabled
untouched is the important one. It imports formatKey from the poisoned module and then never calls it, returning a constant instead. If import alone is enough to kill the function, that variant fails too.
1forge deploy -e development --non-interactive
2forge install-e development -p jira -s your-site.atlassian.net --non-interactive
3forge webtrigger -e development --functionKey poisoned-trigger \4--site your-site.atlassian.net --product jira
Note --functionKey, camelCase. On CLI 13.3.0 the kebab-case --function-key is rejected outright, and forge webtrigger does not accept --non-interactive at all even though deploy and install both do.
untouched returns 424. It never called anything. The import is the failure. Not the call, not the API, not a scope, not a permission. Loading the module is enough.
What the caller gets back is worth reading closely:
There is nothing in there. No message, no stack, no hint that a bundler or an import is involved. 424 Failed Dependency is Forge's generic "your function did not survive" response, and it is what an end user's browser sees.
The stack trace blames the wrong file
forge logs has the real error, and it is a lesson in why you should read stack traces to the bottom:
text
1ERROR BridgeAPIError:
2 Unable to establish a connection with the Custom UI bridge.
3 If you are trying to run your app locally, Forge apps only work in the
4 context of Atlassian products. Refer to
5 https://go.atlassian.com/forge-tunneling-with-custom-ui ...
67 at getCallBridge (@forge/bridge/out/bridge.js:10:1)
8 at Object.9332 (@forge/bridge/out/invoke/invoke.js:8:1)
9 at __webpack_require__ (webpack/bootstrap:19:1)
10 at Object.8350 (@forge/bridge/out/invoke/index.js:4:22)
11 at __webpack_require__ (webpack/bootstrap:19:1)
12 at Object.2321 (@forge/bridge/out/index.js:7:22)
13 at __webpack_require__ (webpack/bootstrap:19:1)
14 at /var/task/poisoned.cjs:7992:11
15 at formatKey (webpack://.../src/poisoned.js:8:3)
16 at Object.<anonymous> (webpack://.../src/poisoned.js:8:3)
Two things to take from this.
First, every frame between the throw and your code is __webpack_require__. This is module loading, not a function call. The chain runs through @forge/bridge/out/index.js — the package barrel — which re-exports ./invoke, and invoke.js throws while being required.
Second, and this is the part that costs people hours: the bottom of the stack says formatKey. formatKey is String(key).trim().toUpperCase(). It has nothing to do with any of this. And shared.js, the file that actually contains the bad import, does not appear in the stack at all. The trace points at the innocent function in the innocent file and stays silent about the guilty one.
If you are debugging this from the log alone, you will stare at a pure string function and conclude Forge is broken.
Why it throws on import
The reason is three lines of shipped code. In @forge/bridge 6.2.0, out/invoke/invoke.js line 8:
js
1const callBridge =(0, bridge_1.getCallBridge)();
That is at module scope. It runs when the module is required, not when you call invoke. out/events/events.js line 6 does the same thing. And getCallBridge in out/bridge.js:
js
1constgetCallBridge=()=>{2if(!isBridgeAvailable(globalThis.__bridge)){3thrownewerrors_1.BridgeAPIError(`4 Unable to establish a connection with the Custom UI bridge.
5 ...
6`);7}8return globalThis.__bridge.callBridge;9};
In a browser inside an Atlassian product, globalThis.__bridge is injected and this is fine. In the Node sandbox it is always absent, so the throw is unconditional. Requiring the barrel requires invoke, which calls getCallBridge(), which throws.
Because it throws during module initialisation, it takes the whole module down. Every export in that file dies, not just the frontend one. That is why formatKey is unreachable despite being harmless.
The error message changed in April, and the old threads are now unfindable
This is the part that makes the bug so much worse than it looks, and it is why searching does not help.
I bisected the published tarballs on line 9 of out/bridge.js:
version
published
reads
error you get in Node
5.14.1
2026-03-16
window.__bridge
ReferenceError: window is not defined
5.15.0
2026-03-30
window.__bridge
ReferenceError: window is not defined
5.15.1
2026-04-13
globalThis.__bridge
BridgeAPIError
6.2.0
2026-08-03
globalThis.__bridge
BridgeAPIError
5 rows × 4 columnsHeader row enabled
You can confirm this yourself in about a minute, and it is worth doing rather than taking my word:
Each version needs its own directory, because every tarball extracts to package/ and they would otherwise overwrite each other — which would quietly leave you testing one version twice. tslib is installed at the top level so both copies resolve it. On 5.15.0 you get ReferenceError. On 6.2.0 you get BridgeAPIError.
Same bug. Same root cause. Same fix. Completely different searchable string.
The community thread I opened with ran from 23 March to 8 April 2026, entirely inside the window.__bridge era. Version 5.15.1 shipped on 13 April, five days after that thread's last reply. So every thread on this subject that Google has indexed and ranked is about an error message that current versions no longer produce, and anyone hitting it today searches BridgeAPIError and finds none of them.
It was not a stealth change, and I want to be accurate about that, because "undocumented" is the easy accusation to make. The package ships its own CHANGELOG.md, and 5.15.1 lists it under Patch Changes:
text
1## 5.15.1
23### Patch Changes
45- 0b7cde8: replace window with globalThis to ensure the bridge packages
6 can work in both browser and worker environment
That is an intentional, sensible change with a stated reason: worker environments have no window. Nothing about it is wrong. What nobody flagged is the side effect, that a patch release silently rewrote the error string thousands of search results point at. The platform changelog on developer.atlassian.com is no help either, because it renders only about the last two weeks; when I fetched it on 4 August it served 20 July to 3 August, so I could not check April there at all.
Your bundler will not save you
The obvious objection is that this should never reach production. subscribeToIssue is unused, events is unused, and webpack in production mode does tree-shaking. It should drop the import.
It does not. I bundled the variants with the CLI's own webpack 5.99.9, mirroring the shipped @forge/bundler 7.1.0 config, which sets mode: 'production' and optimization.minimize: false:
bundle
bytes
contains the bridge
clean
2,367
no
poisoned
241,603
yes
fixed
2,356
no
4 rows × 3 columnsHeader row enabled
That is 102.1x, an extra 239,236 bytes of frontend code shipped into a Node function that cannot use any of it. I deliberately ran this without babel, so webpack saw my source as pure ESM and had the best possible chance at static analysis. It still could not drop it.
One methodology note, because it bit me. Webpack writes module paths into unminified output as comments relative to context, which defaults to process.cwd(). Running the same build from two directories gave me byte counts that differed by single digits and sent me chasing a phantom. Pin context explicitly if you are going to compare sizes at this resolution.
To check the bundles rather than trusting a size difference, grep for a string that only exists inside the bridge:
bash
1grep-c"Unable to establish a connection" out/poisoned.js # 12grep-c"Unable to establish a connection" out/clean.js # 0
So why can't it? Two properties of the published package, both readable in node_modules:
js
1// node_modules/@forge/bridge/package.json2main:"out/index.js"3module:undefined// no ESM entry point, so this is CJS-only4sideEffects:undefined// no side-effect declaration
Without a sideEffects field, webpack has to assume that importing the module might do something observable, so removing it is not provably safe. And with no ESM entry, the barrel is CommonJS built out of tslib.__exportStar(require(...)), which webpack's ESM-based analysis cannot see through.
I wanted to know which of those two actually binds, so I patched one line into the installed package and rebuilt:
bash
1# add "sideEffects": false to node_modules/@forge/bridge/package.json
bundle
bytes
bridge refs
poisoned, as published
241,603
3
poisoned, sideEffects:false
2,620
0
3 rows × 3 columnsHeader row enabled
Restoring the original package.json puts it back to 241,603 exactly, so the patch is what moved it rather than anything else drifting between runs.
The missing sideEffects declaration is the binding constraint. Being CommonJS stops webpack tree-shaking inside the package, but it does not stop webpack dropping the package wholesale once side-effect freedom is declared.
Which sounds like a one-line fix for Atlassian, and here is the closing twist: it isn't one, and the reason is the bug itself. @forge/bridge cannot honestly declare sideEffects: false, because it genuinely does have an import-time side effect. It calls getCallBridge() at module scope and throws. The declaration would be a lie, and a lie that only holds while nothing goes wrong.
The import-time throw is both the symptom and the reason your bundler cannot rescue you from it. They are the same fact. The real fix is upstream and is also one line: make getCallBridge() lazy, called inside invoke rather than at module scope. Then the package has no import-time side effect, sideEffects: false becomes true, and the entire class of bug disappears.
I searched the public FRGE tracker for this and found nothing. That is a real search rather than an assumption — the same query shape returns FRGE-2221 for a term I knew existed, so it was capable of finding a ticket if one were there.
Nothing warns you
Here is what makes this expensive rather than merely annoying. I ran the full toolchain against code that cannot run:
forge lint passes. forge deploy succeeds and prints a tick. There is no build warning, no bundle-size complaint, no note that a frontend-only package landed in a backend function. The first sign of trouble is a 424 in production with an empty error body.
One honest caveat on the lint result. Both times, the CLI also printed:
text
1Warning: Could not perform some linting actions for ServerSideLinter due to
2unhandled error "Pre-Deployment check API is not enabled for this app"
So the client-side linter found nothing, and the server-side linter did not run on my app. I cannot tell you what it would have caught. What I can tell you is that a developer in exactly my position sees No issues found, sees a successful deploy, and has been told nothing.
The fix
Split the module. That is the whole thing, and the reason it has to be a split rather than a rearrangement is that the import itself is the poison, so moving it within the file changes nothing.
js
1// src/shared-core.js — backend-safe, no @forge/bridge anywhere in this file2exportfunctionformatKey(key){3returnString(key).trim().toUpperCase();4}
js
1// src/shared-ui.js — frontend-only, never imported by resolver or trigger code2import{ events }from'@forge/bridge';34exportfunctionsubscribeToIssue(cb){5return events.on('JIRA_ISSUE_CHANGED', cb);6}
js
1// src/fixed.js — the resolver imports only the core2import{ formatKey }from'./shared-core';34exportconstrun=async()=>({5statusCode:200,6headers:{'Content-Type':['application/json']},7body:JSON.stringify({variant:'fixed',key:formatKey(' lz-1 ')})8});
Deployed to the same site, that returns {"variant":"fixed","key":"LZ-1"} with HTTP 200, and bundles to 2,356 bytes with zero bridge references.
1
Find the real import
grep for @forge/bridge across src, then check which of those files are reachable from a resolver, trigger or web trigger entry point. The file that breaks you is usually not the one in the stack trace.
2
Split, do not rearrange
move frontend-only helpers into their own file. Moving the import to the bottom of the shared file does nothing, because loading it is the failure.
3
Verify from the bundle, not the source
build and grep -c "Unable to establish a connection" the output. Zero means the bridge is genuinely gone. A size drop alone can mislead you.
4
Assume a clean lint proves nothing
forge lint returned No issues found on code that returns 424 in production, and its server-side half may not even have run.
What I did not prove
Four limits worth stating, because the measurements above stop where they stop.
The bundle numbers come from webpack invoked directly, mirroring the shipped @forge/bundler 7.1.0 config, not from intercepting a real forge deploy. The live 200/424 results are from real deploys, so the behaviour is confirmed on the platform; it is the byte counts specifically that are reproduced rather than captured.
The Forge CLI also has a second, EAP-gated typescript bundler selectable through app.package.bundler. Everything here is the default webpack path, and I did not test the other one.
I did not establish whether the server-side linter catches this, because the Pre-Deployment check API was not enabled on my test app. Treat "forge lint won't catch it" as proven for the client-side linter only.
And my claim that the 5.15.1 string change makes older threads unfindable is an inference, not a measurement. It rests on the strings genuinely differing, which I verified, and on six community threads whose titles carry the old wording. I have not measured what Google actually ranks today, and I did not confirm that every one of those six has this same root cause rather than another route to a missing window.
The version numbers throughout are the ones I ran on 4 August 2026. Given that the symptom already changed once under a patch release, check yours rather than inheriting mine.
Locking in Forge KVS: FAIL_IF_EXISTS works, TTL leases do not