Skip to main content
Back to Blog
Guide
2026-07-13

Cypress intercept() Streaming-Response Limitations

Understand Cypress intercept streaming-response limitations for SSE and chunked HTTP, then design reliable tests for partial data, reconnects, and completion.

Cypress intercept() Streaming-Response Limitations

The first SSE message updates the progress bar to 25 percent, but Cypress does not show anything until the server closes the response. That observation is the center of this problem. cy.intercept() is excellent at matching a request and supplying or modifying a completed response. It is not a programmable byte-stream producer, and treating a multiline string as four timed chunks does not make it one.

Streaming changes what “the response” means. With ordinary JSON, assertions begin after the body arrives and parsing completes. With Server-Sent Events, newline-delimited feeds, or chunked HTML, user-visible behavior occurs while the connection remains open. Correct tests need control over arrival order, inter-chunk delay, disconnect timing, and sometimes reconnection identifiers. Cypress's interception object does not expose those controls.

This is not an argument against Cypress. It is a boundary-placement issue. Use interception for the parts it models honestly, move stream production into a real local endpoint or a browser-side seam, and keep the assertions in Cypress where they provide value.

What intercept actually hands to the browser

An intercepted request has a request phase and a response phase. A route handler can call req.reply() with a static response, pass through with req.continue(), or listen for response events. On the response object, Cypress exposes properties such as body, headers, statusCode, and controls for total delay or throttling. These describe a response as a value.

For a true stream, a test needs something closer to a writable socket: write headers, flush a first frame, wait, write another frame, and optionally destroy the connection without an orderly end. StaticResponse.body accepts a string, object, or binary data, but it does not offer a sequence of writes. delay delays availability, and throttleKbps changes transfer speed, yet neither lets the test schedule semantic event boundaries.

Required stream behaviorCan a StaticResponse model it?Reason
Return a complete SSE-formatted bodyYesThe body is delivered as one stubbed response value
Delay the whole responseYesdelay adds minimum response latency
Reduce overall transfer ratePartlyThrottling affects bytes, not application-defined event timing
Emit event A, assert UI, then emit event BNoThe route handler has no incremental write API
Keep a stub open indefinitelyNoA static reply represents a response Cypress can complete
Reset the socket after a chosen eventNoforceNetworkError fails the request rather than scripting a mid-body cut
Observe each upstream chunk in req.continue()NoResponse callbacks operate on Cypress's assembled response representation

The distinction also explains why cy.wait('@events') is awkward for a long-lived feed. It waits for the aliased request-response cycle. A successful SSE connection may intentionally remain active for the whole page session, so completion is not the synchronization point the UI test needs.

A static SSE body proves parsing, not streaming

You can return the correct media type and a string containing multiple SSE records. This is useful for a narrow parser or final-state test. It verifies that the application recognizes event, id, and data fields after receiving the payload. It does not verify intermediate rendering, heartbeat handling, or temporal behavior.

// cypress/e2e/report-final-state.cy.ts
describe('report event parsing', () => {
  it('renders the final values from an SSE-formatted response body', () => {
    cy.intercept('GET', '/api/reports/42/events', {
      statusCode: 200,
      headers: {
        'content-type': 'text/event-stream',
        'cache-control': 'no-cache',
      },
      body: [
        'id: 101',
        'event: progress',
        'data: {"percent":25}',
        '',
        'id: 102',
        'event: progress',
        'data: {"percent":100}',
        '',
        'event: complete',
        'data: {"downloadUrl":"/reports/42.pdf"}',
        '',
      ].join('\n'),
    }).as('reportEvents');

    cy.visit('/reports/42');
    cy.contains('100%').should('be.visible');
    cy.get('a[href="/reports/42.pdf"]').should('be.visible');
  });
});

Whether this works at all depends on the application's transport. Native EventSource expects a streaming connection with event-stream semantics, and behavior around a closed static response may differ from a fetch() implementation that reads response.text(). Do not promote this example to evidence that incremental delivery works. Name it after the property it establishes: parsing a completed SSE-formatted payload and rendering its terminal state.

For the normal matching, aliasing, and response-modification surface, use the Cypress network stubbing reference. The testing boundary here starts where a response must remain alive while assertions run.

Run a real SSE endpoint when timing is the behavior

The most faithful local test uses an HTTP server that writes actual frames. Node's response object has the primitives Cypress lacks: writeHead(), write(), flushHeaders(), end(), and destroy(). Put this server in a dedicated test process or start it through Cypress's Node event setup. Avoid creating a new listener per test if parallel specs could contend for a port.

The following Express route is ordinary production-compatible server code. Query parameters select a deterministic scenario. In a larger suite, validate them and restrict the endpoint to the test environment.

// test-server.ts
import express from 'express';

const app = express();

app.get('/test-api/reports/:id/events', (request, response) => {
  response.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
  });
  response.flushHeaders();

  const send = (lines: string[]) => response.write(`${lines.join('\n')}\n\n`);
  send(['retry: 50', 'id: 201', 'event: progress', 'data: {"percent":10}']);

  const halfway = setTimeout(() => {
    send(['id: 202', 'event: progress', 'data: {"percent":55}']);
  }, 150);

  const finish = setTimeout(() => {
    send(['id: 203', 'event: complete', 'data: {"downloadUrl":"/report.pdf"}']);
    response.end();
  }, 300);

  request.on('close', () => {
    clearTimeout(halfway);
    clearTimeout(finish);
  });
});

app.listen(4400, '127.0.0.1', () => {
  console.log('SSE test server listening on http://127.0.0.1:4400');
});

Configure the application under test so its event base URL points to http://127.0.0.1:4400/test-api. If origins differ, the endpoint needs the appropriate CORS headers, and credentials require an explicit allow-origin value rather than *. Another option is to reverse-proxy this endpoint through the application dev server, preserving a same-origin URL.

The Cypress assertion should synchronize on DOM states caused by distinct writes. Because the server waits 150 milliseconds between frames, the test can observe 10 percent before 55 percent. Keep intervals comfortably larger than scheduler noise but short enough for the suite. Do not use cy.wait(150) as the assertion mechanism; retrying DOM assertions already express the desired state.

// cypress/e2e/report-stream.cy.ts
describe('live report progress', () => {
  it('renders intermediate SSE events before completion', () => {
    cy.visit('/reports/stream-fixture');

    cy.contains('[data-testid="progress"]', '10%').should('be.visible');
    cy.contains('[data-testid="progress"]', '55%').should('be.visible');
    cy.contains('[data-testid="progress"]', '100%').should('be.visible');
    cy.get('[data-testid="download-report"]')
      .should('have.attr', 'href', '/report.pdf')
      .and('be.visible');
  });
});

There is still a race if the first write occurs before the page attaches EventSource. A test endpoint can wait for the connection, which it naturally does, but it cannot know whether the application's listener has been registered after constructing the client. Native EventSource dispatches after parsing incoming frames, so sending immediately after headers is generally safe; a small initial delay can make the fixture more observable without changing production logic.

Simulate a mid-stream disconnect deliberately

A pre-response network error and a connection lost after two valid events drive different client code. forceNetworkError: true is useful for “cannot connect” behavior. It does not establish that a client preserves received progress, reconnects with Last-Event-ID, or deduplicates an event replayed after reconnect.

Extend the local server with a scenario that calls response.destroy() after writing a known id. On the next request, inspect request.headers['last-event-id'] if the browser transport is native EventSource, then resume or replay according to server rules. Be cautious: the EventSource specification and browser behavior determine reconnection, while a fetch-based streaming client must implement its own cursor header.

Failure scenarioServer actionAssertion with diagnostic value
Initial connection rejectedRespond 503, optionally with retry metadataUI enters connecting or unavailable state
Socket closes after event 202Write two frames, then destroy responseAlready rendered progress remains, reconnect begins
Last event replayedSend 202 again after reconnectReducer does not apply the update twice
Cursor skippedResume at 204 instead of 203Client requests snapshot or exposes a recoverable gap
Malformed data fieldSend invalid JSON in one complete frameError is contained and later valid events have defined behavior

Use a counter or explicit scenario token on the server rather than global mutable state shared by every spec. Parallel Cypress runners can otherwise consume each other's “first connection.” A per-test UUID in the URL gives the server a safe key, and an after-test cleanup endpoint can remove scenario state.

Test a browser-side transport seam for exhaustive sequences

An actual server gives protocol fidelity, but dozens of edge cases become slow and awkward when every event requires a timer. A second layer can inject a transport interface into the application. Production wraps EventSource; component or end-to-end test builds expose a controlled implementation whose emit(), error(), and close() methods are synchronous.

This seam tests the state machine, not HTTP. It can verify a hundred ordering permutations quickly: progress after completion, duplicate IDs, invalid transition, and component unmount. Keep one or more real-server tests so the adapter itself remains covered. If every test replaces EventSource, a broken URL, missing credentials option, or parsing bug can ship unnoticed.

Cypress can access a test-only controller attached to window, but type it and guard its creation behind a compile-time test flag. Avoid using cy.stub(window, 'EventSource') after the application has already opened the connection. Install the substitute before application bootstrap through onBeforeLoad or dependency injection.

Understand chunked transfer without over-testing framing

HTTP/1.1 chunked transfer encoding is a wire-level mechanism. Application code usually sees decoded response bytes from Fetch streams, not chunk-size markers. HTTP/2 does not use the same transfer-encoding framing at all. Therefore a test should describe observable byte or record boundaries, not assert that a specific Transfer-Encoding: chunked header appears in every environment.

A reverse proxy may buffer upstream chunks, compression can coalesce writes, and a framework can flush headers differently between development and production. For latency-sensitive behavior, run at least one test through the deployed proxy configuration. The local Express fixture proves client logic under controlled writes; it cannot certify that a CDN forwards those writes immediately.

When using fetch() and ReadableStreamDefaultReader, multibyte UTF-8 characters can be split across chunks. The client should use one streaming TextDecoder with { stream: true } instead of decoding each byte array independently. An integration fixture that splits an emoji between writes is more valuable than asserting arbitrary network packet boundaries.

Choose the smallest honest test double

The tool choice follows the behavior under examination, not a blanket rule that all network calls must be intercepted.

ApproachStrongest useImportant limitation
cy.intercept() StaticResponseCompleted payload, headers, status, normal request assertionCannot schedule incremental semantic writes
Local Node SSE serverArrival order, open connection, reconnect, abrupt closeAdds lifecycle and port management
Test-only transport adapterLarge state-machine matrix with precise controlDoes not exercise browser networking
Service worker mockBrowser-visible request handling close to the appStreaming support and test coordination require careful implementation
Deployed environmentProxy buffering, auth, infrastructure timeoutsSlow, less deterministic, harder failure diagnosis

For bidirectional, message-oriented traffic, WebSocket tests have a different handshake and framing model. The WebSocket testing guide covers that surface. Do not treat SSE as “WebSocket but one-way”; browser APIs, reconnection, intermediaries, and test controls differ.

Prevent false confidence in the final-state assertion

A screen ending at 100 percent does not prove that it displayed intermediate progress. The application might buffer everything, skip directly from 0 to 100, and still pass. Capture state transitions when they matter. DOM assertions at controlled server intervals are one method. A test-only transition log is another, provided the log records the production reducer's actions rather than a duplicate test implementation.

Also assert connection cleanup. Navigating away should close EventSource or abort the fetch reader. Otherwise every test may pass while production accumulates sockets and duplicate listeners. The local server can record request.on('close') and expose a diagnostic query, or a component test can spy on the adapter's close() method.

Keep event payload validation separate from transport behavior. Schema tests can feed records directly into the decoder. Streaming integration tests should focus on boundaries that only a live connection reveals. This division makes failures legible: decoder defect, state transition defect, browser adapter defect, or infrastructure buffering.

Verify the production proxy does not undo streaming

A local Node route can flush perfectly while the deployed ingress waits for a buffer to fill. Compression middleware, serverless response adapters, reverse proxies, and CDN features can each change when bytes become visible. Add a small environment-level probe that records client receipt time for numbered heartbeat events generated at known intervals. Judge the gaps with generous bounds because shared CI machines and networks are noisy, but fail when every event arrives together after the server ends.

Do not expose a permanent unauthenticated diagnostic stream containing internal timing data. The probe can use the normal authenticated endpoint with a test resource, or a restricted health route enabled only in a preproduction environment. Clean up the stream even on assertion failure so the job does not leave connections consuming worker capacity.

Browser developer tools can help distinguish layers. If the network panel shows an open request while the UI remains unchanged, inspect the application's reader and decoder. If no bytes appear until completion, compare direct-origin and proxied URLs. If direct origin streams and the proxy does not, changing the Cypress route cannot repair the infrastructure behavior.

Response compression deserves a targeted check because small SSE frames may sit in a compressor's buffer. Many deployments disable transformation for event streams or use headers and middleware settings that encourage immediate flushing. The exact control is server-specific, so test observable arrival instead of asserting a configuration key copied from another proxy.

Finally, bound the connection at the test-server layer. A failed Cypress assertion can stop normal page cleanup before the server's finish timer runs. Track active responses, close them in suite teardown, and fail startup if the selected port is already occupied. These details sound mundane, but an orphaned stream is a common reason a locally reliable spec hangs only on a parallel CI worker.

Frequently Asked Questions

Can throttleKbps make separate SSE messages arrive on a schedule?

No. It limits the response transfer rate in aggregate. Byte speed can influence arrival, but it does not give the test a supported way to flush one SSE record, pause for an assertion, and then flush the next record.

Why does cy.wait on my SSE alias time out?

cy.wait() expects the matched request-response cycle to reach the point Cypress can yield it. An SSE feed is designed to remain open, so completion may never occur within the command timeout. Synchronize on application state or use a controllable stream server instead.

Is forceNetworkError equivalent to dropping an established stream?

It models a network failure for the intercepted request, not a scripted disconnect after selected body chunks have reached the page. Use a socket-capable test server to verify preservation and reconnection after partial delivery.

Should an SSE test assert the Transfer-Encoding header?

Usually not. The application-visible contract is event-stream media type and progressive records. HTTP version, proxying, and server framework can change wire framing. Assert observable incremental behavior and validate production proxy buffering separately.

Can I keep intercept for non-stream endpoints in the same spec?

Yes. Stub deterministic setup calls with cy.intercept() and route only the live feed to the local SSE fixture. Register intercepts before cy.visit(), and ensure broad matchers do not accidentally capture the stream URL.

Cypress intercept() Streaming-Response Limitations | QASkills.sh