Skip to content

web: second Firefox tab never connects to /api/event - Cache-Control: no-cache lets Firefox's HTTP cache block concurrent SSE requests #55369

Open

Description

@LeonMueller-OneAndOnly

Description

In Firefox (also Firefox-based browsers such as Zen), the web UI's live event stream hangs as soon as the same opencode server is open in a second tab. The banner/loading state never clears until the other tab is closed.

GET /api/event is answered with Cache-Control: no-cache, no-transform. no-cache still allows storing, so Firefox writes the response into its HTTP cache. A concurrent request for the same URL then waits for that cache entry to finish being written - which never happens for an endless SSE stream. The second tab's /api/event requests are queued inside the browser forever:

  • DevTools shows them with no status, 0 B and "No timings for request" - they are never sent.
  • The server never sees them (nothing in the opencode log, no connection attempt).
  • The web client aborts each attempt after its 2 s connect deadline and retries, so the queue just grows.
  • Other requests to the same host at the same moment (e.g. /api/session/active) succeed instantly over the same HTTP/2 connection, so this is not a connection or stream limit - only the repeated /api/event URL is blocked.
  • Closing the other tab makes the stream connect immediately.

Server responses confirmed from opencode serve v2.0.23:

HTTP/1.1 200 OK
cache-control: no-cache, no-transform
x-accel-buffering: no
content-type: text/event-stream
Transfer-Encoding: chunked

Suggested fix

Either (or both):

  1. Server: send Cache-Control: no-store (e.g. no-store, no-transform) for /api/event (and other SSE endpoints) in packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts. Event streams should never be stored.
  2. Client: issue the event-stream fetch with cache: "no-store", which bypasses the HTTP cache entirely.

We worked around it by wrapping window.fetch in the web UI and re-issuing /api/event as new Request(request, { headers, cache: "no-store" }).

Steps to reproduce

  1. opencode serve, open the web UI in Firefox.
  2. Open the same server/project in a second Firefox tab.
  3. The second tab never receives live events; DevTools > Network shows /api/event requests piling up without status or timings.
  4. Close the first tab - the second connects immediately.

Related

#48771, #53498, #51857 describe stalled/never-connecting event streams with other causes; this is a further, browser-side cause with the same symptom.

OpenCode version

2.0.23

Operating System

macOS (Firefox 157 / Zen)

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report! I could reproduce this.

    On 2.0.23 I ran opencode serve and opened the web UI in two tabs of the same Firefox profile (Playwright's Firefox):

    • The first tab's /api/event got 200 with cache-control: no-cache, no-transform.
    • In 15 seconds the second tab sent 5 /api/event requests and got no response to any of them.
    • A plain fetch("/api/event") from the second tab hung. The same fetch with cache: "no-store" got 200 immediately.
    • After I closed the first tab, the second tab connected right away.

    It still happens on the latest dev (34d8ded). The server still sends Cache-Control: no-cache, no-transform for the event stream, and the client doesn't set cache: "no-store". There, two Firefox tabs fetching /api/event showed the same result: the second hangs until the first closes, unless it uses no-store.

    The script below reproduces it with the web UI. Until there's a fix, your workaround of forcing cache: "no-store" on /api/event is the way around it. This may also contribute to the symptoms in #53498, #51857 and #48771, although each of those has its own cause.

    Reproduction script (firefox-two-tabs.mjs)
    // Reproduces #55369: in Firefox, a second tab on the same opencode server never
    // gets a response for GET /api/event while the first tab's stream is open.
    // Usage: OC_URL=http://127.0.0.1:4096 OC_PASSWORD=<server password> node firefox-two-tabs.mjs
    // Requires: npm i playwright && npx playwright install --with-deps firefox
    import { firefox, chromium } from "playwright"
    import { createHmac } from "node:crypto"
    
    const url = process.env.OC_URL ?? "http://127.0.0.1:4096"
    const password = process.env.OC_PASSWORD
    const engine = process.env.BROWSER === "chromium" ? chromium : firefox
    
    const browser = await engine.launch()
    const context = await browser.newContext()
    // Authenticate like a paired browser: a signed session cookie (see packages/server/src/auth.ts).
    if (password) {
      const expires = String(Math.floor(Date.now() / 1000) + 3600)
      const key = createHmac("sha256", password).update("opencode-session-v1").digest()
      const token = `${expires}.${createHmac("sha256", key).update(expires).digest("base64url")}`
      const u = new URL(url)
      await context.addCookies([{ name: `opencode_session_${u.port}`, value: token, url }])
    }
    
    function track(page, name) {
      const stats = { name, requests: 0, responses: 0, statuses: [] }
      page.on("request", (r) => r.url().includes("/api/event") && stats.requests++)
      page.on("response", (r) => {
        if (!r.url().includes("/api/event")) return
        stats.responses++
        stats.statuses.push(r.status() + " " + (r.headers()["cache-control"] ?? ""))
      })
      return stats
    }
    
    const tab1 = await context.newPage()
    const s1 = track(tab1, "tab1")
    await tab1.goto(url)
    await tab1.waitForTimeout(8000)
    
    const tab2 = await context.newPage()
    const s2 = track(tab2, "tab2")
    await tab2.goto(url)
    await tab2.waitForTimeout(15000)
    console.log("with tab1 open:", JSON.stringify(s1), JSON.stringify(s2))
    
    // Direct probe from tab2: does a plain fetch of /api/event get response headers?
    const probe = async (cache) =>
      tab2.evaluate(async (cache) => {
        const ac = new AbortController()
        const t = setTimeout(() => ac.abort(), 5000)
        try {
          const r = await fetch("/api/event", { signal: ac.signal, cache })
          ac.abort()
          return "status " + r.status
        } catch (e) {
          return "no response within 5s (" + e.name + ")"
        } finally {
          clearTimeout(t)
        }
      }, cache)
    console.log("tab2 fetch(/api/event) default cache:", await probe("default"))
    console.log("tab2 fetch(/api/event) cache:no-store:", await probe("no-store"))
    
    await tab1.close()
    const before = s2.responses
    await tab2.waitForTimeout(8000)
    console.log("after closing tab1: tab2 new /api/event responses =", s2.responses - before)
    
    await browser.close()

    Run it: bun firefox-two-tabs.mjs

  2. zonemeen commented on Oct 14, 2026

    @zonemeen

    Thanks for the detailed report and reproduction steps! I’d like to work on this. I’ll update the SSE cache handling to use no-store and verify that both Firefox tabs can connect before opening a PR.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions