DeepSeek API Streaming: Python, Node.js, and cURL Examples

DeepSeek API streaming lets an application receive a response as incremental deltas instead of waiting for one complete JSON object. With the Chat Completions API, enable it by sending "stream": true; DeepSeek then returns data-only Server-Sent Events, or SSE, that your code can process as they arrive.

This implementation guide shows how to inspect the raw stream with cURL, consume it with Python and Node.js, separate reasoning_content from the final answer, collect usage data, detect completion, cancel an active request, and forward output to a browser. For authentication and first-request setup, start with our DeepSeek API guide; this page focuses on streaming behavior.

Last verified: October 16, 2026. These examples target the current DeepSeek Chat Completions endpoint and the documented deepseek-flash model. Protocol details are based on the official Chat Completions reference.

Protocol scope: The code below targets POST https://api.deepseek.com/chat/completions. DeepSeek also supports a Responses API format, but that interface uses a different streaming event model. Do not mix its events with the Chat Completions parser in this guide.

Example scope: The complete examples are single-turn, text-prompt demonstrations without tool execution. They use an illustrative max_tokens: 4096 cap, not a provider maximum; thinking can consume that output allowance. Live API calls require a DeepSeek key and sufficient API balance. The parser and interface checks described here use synthetic streams, not paid inference or performance benchmarks.

How DeepSeek API streaming works

A non-streaming request waits for generation to finish and returns one completed response object. A streaming request opens an HTTP response and sends a sequence of SSE records. Each JSON record may contain a role update, reasoning fragment, answer fragment, finish reason, usage information, or no visible text at all.

Streaming can reduce the time before a user sees the beginning of an answer, but it does not guarantee that the model generates faster. It also does not make the request cheaper by itself. Billing still depends on the applicable model and token usage documented by DeepSeek.

Keep these four layers separate:

  • Network read: An arbitrary group of bytes returned by the HTTP client. It may contain part of an event or several events.
  • SSE event: One or more SSE fields terminated by a blank line.
  • DeepSeek chunk: The JSON object contained in an SSE data: field.
  • Text delta: An optional string inside choices[].delta.content or choices[].delta.reasoning_content.

A text delta is not guaranteed to equal exactly one model token. Never build accounting, animation timing, or parser logic around a one-chunk-per-token assumption.

Stream itemWhat it meansWhat your code should do
: keep-aliveAn SSE comment sent while the request remains connected.Ignore it as content. Do not pass it to JSON.parse().
data: {...}A JSON Chat Completions chunk.Parse it only after the complete SSE event has been assembled.
delta.reasoning_contentA reasoning fragment when thinking output is present.Collect it separately from the user-facing answer.
delta.contentA final-answer fragment.Append it without trimming and render it once.
Non-null finish_reasonThe model has reached a defined stopping condition.Save and inspect the reason before marking the request successful.
usageToken accounting attached to the final chunk.Save it for observability and cost tracking.
data: [DONE]The Chat Completions wire-level termination marker.Stop reading. Do not parse it as JSON.

Test the raw stream with cURL

Keep your DeepSeek API key in a server-side environment variable. On macOS or Linux, use export DEEPSEEK_API_KEY="your-key". In PowerShell, use $env:DEEPSEEK_API_KEY = "your-key". Do not commit the key or share a terminal capture containing it. Save this JSON as request.json:

{
  "model": "deepseek-flash",
  "messages": [
    {
      "role": "user",
      "content": "Explain why streamed JSON needs an incremental parser in three concise points."
    }
  ],
  "stream": true,
  "stream_options": {
    "include_usage": true
  },
  "thinking": {
    "type": "enabled"
  },
  "reasoning_effort": "high",
  "max_tokens": 4096
}

In Bash or zsh, run:

curl --no-buffer --fail-with-body --show-error \
  https://api.deepseek.com/chat/completions \
  -H "Authorization: Bearer $DEEPSEEK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  --data-binary "@request.json"

In Windows PowerShell, use the native curl.exe executable with the same JSON file:

curl.exe --no-buffer --fail-with-body --show-error https://api.deepseek.com/chat/completions -H "Authorization: Bearer $env:DEEPSEEK_API_KEY" -H "Content-Type: application/json" -H "Accept: text/event-stream" --data-binary "@request.json"

The important cURL option is --no-buffer, also available as -N. It disables cURL’s normal output buffering so SSE records can appear as they arrive. This affects cURL’s local output behavior; it cannot disable buffering performed by an intermediary proxy or CDN.

The output may begin with an empty or metadata-only delta. Thinking-mode requests may then provide reasoning_content, followed by answer text in content. DeepSeek documents a final JSON chunk with usage and one choice carrying a non-null finish_reason but no new answer text, followed by data: [DONE]. Do not wait for a separate usage-only chunk with an empty choices array.

cURL displays the wire format; it does not assemble or validate a complete answer. Use it to inspect field names and isolate buffering, but do not equate a successful cURL exit with an application-level stop result.

Stream DeepSeek responses with Python

Use Python 3.10 or newer for the annotations in this example. DeepSeek’s OpenAI-compatible Chat Completions interface works with the OpenAI Python SDK. Install the package with:

python -m pip install openai

Save the following as deepseek-stream.py. It prints answer deltas immediately, keeps reasoning separate, retains partial output after failure, and reports usage even when the request is interrupted. A missing finish reason is not a successful answer; missing usage is not a zero-cost request.

from __future__ import annotations

import json
import os
import sys
from typing import Any

import openai
from openai import OpenAI

PROMPT = "Explain why streamed JSON needs an incremental parser in three concise points."


def main() -> int:
    api_key = os.environ.get("DEEPSEEK_API_KEY")
    if not api_key:
        print("Set DEEPSEEK_API_KEY before running this script.", file=sys.stderr)
        return 2

    answer_parts: list[str] = []
    reasoning_parts: list[str] = []
    finish_reason: str | None = None
    usage: dict[str, Any] | None = None
    stream_ended = False
    failure: str | None = None

    try:
        with OpenAI(
            api_key=api_key,
            base_url="https://api.deepseek.com",
            max_retries=0,
        ) as client:
            stream = client.chat.completions.create(
                model="deepseek-flash",
                messages=[{"role": "user", "content": PROMPT}],
                stream=True,
                max_tokens=4096,
                stream_options={"include_usage": True},
                reasoning_effort="high",
                extra_body={"thinking": {"type": "enabled"}},
            )

            with stream:
                for chunk in stream:
                    if chunk.usage is not None:
                        usage = chunk.usage.model_dump()

                    for choice in chunk.choices:
                        if choice.finish_reason is not None:
                            finish_reason = choice.finish_reason

                        delta = choice.delta
                        reasoning_delta = getattr(
                            delta,
                            "reasoning_content",
                            None,
                        )
                        content_delta = getattr(delta, "content", None)

                        # Check the two optional fields independently.
                        if isinstance(reasoning_delta, str) and reasoning_delta:
                            reasoning_parts.append(reasoning_delta)

                        if isinstance(content_delta, str) and content_delta:
                            answer_parts.append(content_delta)
                            print(content_delta, end="", flush=True)

            stream_ended = True

    except KeyboardInterrupt:
        failure = "cancelled by the user"
    except openai.APIStatusError as exc:
        failure = f"HTTP {exc.status_code}; check the error-code guide"
    except openai.APITimeoutError:
        failure = "request timed out"
    except openai.APIConnectionError as exc:
        failure = "connection error"
    except Exception as exc:
        failure = f"unexpected error: {type(exc).__name__}"

    final_answer = "".join(answer_parts)
    reasoning_text = "".join(reasoning_parts)

    if stream_ended and finish_reason is None:
        failure = failure or "stream ended without a final finish_reason"

    print()

    # Always retain metadata, including when the stream is interrupted.
    print(f"Finish reason: {finish_reason}", file=sys.stderr)
    print(f"Reasoning captured: {len(reasoning_text)} characters", file=sys.stderr)
    if usage is not None:
        print(json.dumps({"usage_status": "received", "usage": usage}, indent=2), file=sys.stderr)
    else:
        print("Usage unavailable: keep billing pending; do not record zero cost.", file=sys.stderr)

    if failure:
        print(f"Stream interrupted: {failure}", file=sys.stderr)
        print(f"Partial answer preserved: {len(final_answer)} characters", file=sys.stderr)
        return 1

    if finish_reason != "stop":
        print("Not a normal completed answer; inspect the finish reason.", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Run it with python deepseek-stream.py after setting DEEPSEEK_API_KEY. The printed summary and in-memory buffers are not a durable billing ledger.

Why the Python example is structured this way

  • max_retries=0 disables automatic SDK retries for retryable request failures. It does not add stream resumption; application-level retries still need an explicit policy.
  • The stream and client use context managers, ensuring their HTTP resources are closed when iteration finishes, an exception occurs, or the user presses Ctrl+C.
  • reasoning_content and content are checked independently. Optional fields may be absent, null, or empty.
  • Individual deltas are not passed through strip(). Removing whitespace from each fragment can merge words or destroy Markdown formatting.
  • The answer is printed and accumulated at the same time. Printing alone would leave the program without a complete value to store after generation.
  • A normally exhausted iterator is not considered sufficient evidence of success. The code also requires a final finish_reason.

The current Python SDK accepts a timeout option on the client or an individual request. Choose that value according to your application rather than copying a short arbitrary deadline into every streaming workflow. DeepSeek may send keep-alive comments while a request is still waiting to begin inference.

Stream DeepSeek responses with Node.js

Use Node.js 22 or a newer supported release for the examples below. This server-side JavaScript example uses the OpenAI JavaScript SDK, keeps both output channels separate, and supports cancellation through an AbortController. An optional application timer remains active while the response body is being read.

Create an ES module project and install the SDK:

mkdir deepseek-stream
cd deepseek-stream
npm init -y
npm pkg set type=module
npm install openai

Save the following as deepseek-stream.mjs:

import OpenAI from "openai";

const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
  console.error("Set DEEPSEEK_API_KEY before running this script.");
  process.exit(2);
}
const timeoutMs = Number(process.env.DEEPSEEK_TIMEOUT_MS ?? 0);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) {
  console.error("DEEPSEEK_TIMEOUT_MS must be 0 or a positive integer <= 2147483647.");
  process.exit(2);
}
const client = new OpenAI({ apiKey, baseURL: "https://api.deepseek.com", maxRetries: 0 });
const controller = new AbortController();
const cancel = () => controller.abort();
process.once("SIGINT", cancel);
let timedOut = false;
// This timer remains active while the response body is being consumed.
const deadline = timeoutMs > 0 ? setTimeout(() => {
  timedOut = true;
  controller.abort();
}, timeoutMs) : null;
const answerParts = [];
const reasoningParts = [];
let finishReason = null;
let usage = null;
let failure = null;

try {
  const stream = await client.chat.completions.create({
    model: "deepseek-flash",
    messages: [{ role: "user", content: "Explain why streamed JSON needs an incremental parser in three concise points." }],
    stream: true,
    stream_options: { include_usage: true },
    thinking: { type: "enabled" },
    reasoning_effort: "high",
    max_tokens: 4096,
  }, { signal: controller.signal });

  for await (const chunk of stream) {
    controller.signal.throwIfAborted();
    if (chunk.usage != null) usage = chunk.usage;
    for (const choice of chunk.choices ?? []) {
      if (choice.finish_reason != null) finishReason = choice.finish_reason;
      const reasoningDelta = choice.delta?.reasoning_content;
      const contentDelta = choice.delta?.content;
      if (typeof reasoningDelta === "string" && reasoningDelta) reasoningParts.push(reasoningDelta);
      if (typeof contentDelta === "string" && contentDelta) {
        answerParts.push(contentDelta);
        process.stdout.write(contentDelta);
      }
    }
  }
  // An SDK may end iteration on abort instead of throwing: check explicitly.
  controller.signal.throwIfAborted();
  if (finishReason === null) throw new Error("Stream ended without a final finish_reason.");
} catch (error) {
  failure = timedOut ? "Application deadline exceeded."
    : controller.signal.aborted ? "Cancelled by the user."
    : typeof error?.status === "number" ? `DeepSeek HTTP ${error.status}.`
    : error?.message ?? "The stream failed.";
} finally {
  clearTimeout(deadline);
  process.removeListener("SIGINT", cancel);
  controller.abort(); // Release any remaining HTTP work.
}

const finalAnswer = answerParts.join("");
console.error(`\nFinish reason: ${finishReason}`);
console.error(`Reasoning captured: ${reasoningParts.join("").length} characters`);
if (usage !== null) {
  console.error(JSON.stringify({ usage_status: "received", usage }, null, 2));
} else {
  console.error("Usage unavailable: keep billing pending; do not record zero cost.");
}
if (failure) {
  console.error(`Interrupted: ${failure} Partial answer: ${finalAnswer.length} characters.`);
  process.exitCode = 1;
} else if (finishReason !== "stop") {
  console.error("Not a normal completed answer; inspect the finish reason.");
  process.exitCode = 1;
}

Run it with:

node deepseek-stream.mjs

Press Ctrl+C to abort the request. Set DEEPSEEK_TIMEOUT_MS to a positive integer to add a whole-operation application deadline; use 0 or leave it unset to add no such timer. The SDK and infrastructure can still apply their own timeouts. The sample clears its timer only after iteration finishes or fails, rather than assuming that an SDK request-timeout option covers every stage of a long-lived stream.

The examples disable automatic SDK retries deliberately. Retrying before visible output can still repeat work already accepted by a provider. After partial output, a replacement generation may repeat or change the answer; never append it silently to the interrupted result.

Handle reasoning_content without corrupting the answer

DeepSeek Thinking Mode is enabled by default in the current documentation, with high as the default effort. The examples set both explicitly. For non-thinking requests, set {"thinking":{"type":"disabled"}} and omit the example’s reasoning_effort setting rather than sending conflicting controls.

When thinking output is present, DeepSeek returns it through reasoning_content at the same level as content. Treat these as separate output channels:

  • Use reasoning_content for a separate diagnostic, expandable, or discardable buffer.
  • Use content to construct the final answer shown to the user.
  • Do not blindly concatenate reasoning and answer fields into one string.
  • Do not assume each chunk contains only one of the fields. Check both independently.
  • Do not treat reasoning output as a guaranteed complete or authoritative explanation of how the model reached its answer.

In the Python SDK, pass the provider-specific thinking object inside extra_body. Keep reasoning out of the final-answer string regardless of whether the interface chooses to display it separately.

Multi-turn replay rules depend on whether the next request includes tools. Without tools, earlier reasoning_content need not be returned and is ignored if supplied. With tools, DeepSeek requires previous reasoning to be passed back completely; omission can cause a 400 response. The examples on this page do not execute tools. Use our DeepSeek tool-calling guide for the execution loop and the official reasoning replay rules for the provider requirements.

Parse raw SSE without assuming one read equals one event

SDK iterators hide most wire-level parsing. If you use fetch(), a reverse proxy, or a custom HTTP client, you must parse the event stream incrementally.

This code is unsafe:

// Do not do this.
const { value } = await reader.read();
const chunk = JSON.parse(new TextDecoder().decode(value));

A single reader.read() call may stop in the middle of a JSON object, split a multibyte UTF-8 character, contain several SSE events, or contain only a keep-alive comment.

The following focused parser keeps a text buffer, handles LF, CRLF and CR line endings, joins data: fields, ignores comments, and preserves UTF-8 characters across byte boundaries. These framing rules follow the SSE standard. Save it as sse-parser.mjs.

function takeLine(buffer, endOfStream = false) {
  for (let index = 0; index < buffer.length; index += 1) {
    const character = buffer[index];

    if (character === "\n") {
      const lineEnd =
        index > 0 && buffer[index - 1] === "\r"
          ? index - 1
          : index;

      return {
        line: buffer.slice(0, lineEnd),
        rest: buffer.slice(index + 1),
      };
    }

    if (character === "\r") {
      if (index + 1 === buffer.length && !endOfStream) {
        return null;
      }

      const nextIndex =
        buffer[index + 1] === "\n"
          ? index + 2
          : index + 1;

      return {
        line: buffer.slice(0, index),
        rest: buffer.slice(nextIndex),
      };
    }
  }

  if (endOfStream && buffer.length > 0) {
    return { line: buffer, rest: "" };
  }

  return null;
}

export async function* parseSSE(body, onActivity = () => {}) {
  if (!body) {
    throw new Error("The response does not contain a readable body.");
  }

  const reader = body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buffer = "";
  let eventName = "message";
  let dataLines = [];
  let dataChars = 0;
  const maxEventChars = 1_000_000; // Local guard, not a DeepSeek limit.

  const processLine = (line) => {
    onActivity();
    if (line.length > maxEventChars) throw new Error("SSE line too large.");

    if (line === "") {
      if (dataLines.length === 0) {
        eventName = "message";
        return null;
      }

      const event = {
        event: eventName,
        data: dataLines.join("\n"),
      };

      eventName = "message";
      dataLines = [];
      dataChars = 0;
      return event;
    }

    // SSE comments include DeepSeek's ": keep-alive" messages.
    if (line.startsWith(":")) {
      return null;
    }

    const colonIndex = line.indexOf(":");
    const field =
      colonIndex === -1
        ? line
        : line.slice(0, colonIndex);

    let value =
      colonIndex === -1
        ? ""
        : line.slice(colonIndex + 1);

    if (value.startsWith(" ")) {
      value = value.slice(1);
    }

    if (field === "event") {
      eventName = value || "message";
    } else if (field === "data") {
      dataChars += value.length + 1;
      if (dataChars > maxEventChars) throw new Error("SSE event too large.");
      dataLines.push(value);
    }

    return null;
  };

  try {
    while (true) {
      const { done, value } = await reader.read();
      buffer += decoder.decode(value, { stream: !done });

      while (true) {
        const parsed = takeLine(buffer, done);
        if (!parsed) {
          break;
        }

        buffer = parsed.rest;
        const event = processLine(parsed.line);

        if (event) {
          yield event;
        }
      }

      if (buffer.length > maxEventChars) throw new Error("SSE line too large.");

      if (done) {
        break;
      }
    }

    if (dataLines.length > 0) {
      throw new Error(
        "The SSE connection ended before the current event was complete.",
      );
    }
  } finally {
    try { await reader.cancel(); } catch { /* Already closed or aborted. */ }
    reader.releaseLock();
  }
}

This consumer deliberately reports an unfinished data event as an error instead of promoting it to a complete event at EOF. It cancels the reader when its caller stops early and releases the lock. The one-million-character guards are local defensive limits, not DeepSeek’s token or event limits. This is a parser for a single response, not an EventSource reconnection or Last-Event-ID implementation.

The parser intentionally returns the SSE data value as text. Its caller must check for [DONE] before attempting JSON parsing:

let sawDone = false;
for await (const event of parseSSE(response.body)) {
  if (event.data === "[DONE]") {
    sawDone = true;
    break;
  }
  const chunk = JSON.parse(event.data);
  // Process choices, deltas, finish_reason, and usage.
}
if (!sawDone) throw new Error("Stream ended without [DONE].");

Stream output to a browser without exposing the API key

The normal architecture for a browser chat interface is:

Browser → your backend → DeepSeek API

Do not place DEEPSEEK_API_KEY in a browser bundle, HTML attribute, public environment variable, or frontend request header. A user can inspect all of those values.

The following example uses an Express backend to consume DeepSeek SSE and send a small newline-delimited JSON (NDJSON) protocol to the browser. It imports sse-parser.mjs from the previous section. The backend checks [DONE]; the browser checks a separate application message, {"type":"done", ...}. Those two markers are not interchangeable.

Local demo, not a public production service: The server binds to 127.0.0.1. It includes an input-size limit and an output cap but does not implement user authentication, authorization, per-user budgets, rate limits, or durable billing storage. Add those controls before deploying it publicly. Do not paste or execute the demo application code inside WordPress.

1. Create the backend

npm install express

Save this as server.mjs:

import express from "express";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { parseSSE } from "./sse-parser.mjs";

// Dependency injection lets tests supply synthetic streams without an API call.
export function createApp({ apiKey, fetchImpl = fetch, deadlineMs = 0 } = {}) {
  if (!apiKey) throw new Error("Set DEEPSEEK_API_KEY before starting the server.");
  if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0 || deadlineMs > 2_147_483_647) {
    throw new Error("DEEPSEEK_TIMEOUT_MS must be 0 or a positive integer <= 2147483647.");
  }
  const app = express();
  app.disable("x-powered-by");
  app.use(express.json({ limit: "16kb" }));
  app.use(express.static(fileURLToPath(new URL("./public", import.meta.url))));

  app.post("/api/deepseek-stream", async (req, res) => {
    const prompt = typeof req.body?.prompt === "string" ? req.body.prompt.trim() : "";
    if (!prompt) return res.status(400).json({ error: "A non-empty prompt is required." });

    const controller = new AbortController();
    let timedOut = false;
    let upstream = null;
    let heartbeat = null;
    let finishReason = null;
    let usage = null;
    let responseId = null;
    let returnedModel = null;
    let sawDone = false;
    const abortUpstream = () => controller.abort();
    req.once("aborted", abortUpstream);
    res.once("close", abortUpstream);
    const deadline = deadlineMs > 0 ? setTimeout(() => {
      timedOut = true;
      controller.abort();
    }, deadlineMs) : null;

    const sendEvent = async (event) => {
      controller.signal.throwIfAborted();
      if (res.destroyed || res.writableEnded) throw new Error("Browser connection closed.");
      if (!res.write(`${JSON.stringify(event)}\n`)) {
        await once(res, "drain", { signal: controller.signal });
      }
    };

    try {
      upstream = await fetchImpl("https://api.deepseek.com/chat/completions", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          Accept: "text/event-stream",
        },
        body: JSON.stringify({
          model: "deepseek-flash",
          messages: [{ role: "user", content: prompt }],
          stream: true,
          stream_options: { include_usage: true },
          reasoning_effort: "high",
          thinking: { type: "enabled" },
          max_tokens: 4096,
        }),
        signal: controller.signal,
      });
      controller.signal.throwIfAborted();
      if (!upstream.ok) {
        return res.status(upstream.status).json({
          error: `DeepSeek rejected the request with HTTP ${upstream.status}.`,
        });
      }
      const mime = upstream.headers.get("content-type")?.split(";")[0].trim();
      if (mime !== "text/event-stream" || !upstream.body) {
        throw new Error("Expected DeepSeek SSE response.");
      }
      res.status(200).set({
        "Content-Type": "application/x-ndjson; charset=utf-8",
        "Cache-Control": "no-cache, no-transform",
        "X-Accel-Buffering": "no",
      });
      res.flushHeaders();
      heartbeat = setInterval(() => {
        if (!controller.signal.aborted && !res.destroyed && !res.writableEnded &&
            !res.writableNeedDrain) {
          // Backend liveness only: this does not mean inference has begun.
          res.write('{"type":"heartbeat"}\n');
        }
      }, 15_000);

      for await (const event of parseSSE(upstream.body)) {
        controller.signal.throwIfAborted();
        if (event.event !== "message") throw new Error("Unexpected upstream event type.");
        if (event.data === "[DONE]") {
          sawDone = true;
          break;
        }
        const chunk = JSON.parse(event.data);
        if (!chunk || chunk.error || !Array.isArray(chunk.choices)) {
          throw new Error("Invalid upstream completion chunk.");
        }
        if (typeof chunk.id === "string") responseId = chunk.id;
        if (typeof chunk.model === "string") returnedModel = chunk.model;
        if (chunk.usage != null) {
          if (typeof chunk.usage !== "object" || Array.isArray(chunk.usage)) {
            throw new Error("Invalid upstream usage object.");
          }
          usage = chunk.usage;
        }
        // This single-answer demo does not send tools or request multiple choices.
        for (const choice of chunk.choices) {
          if (choice.index !== 0) throw new Error("Unexpected choice index.");
          if (choice.finish_reason != null) {
            if (typeof choice.finish_reason !== "string" || !choice.finish_reason) {
              throw new Error("Invalid finish reason.");
            }
            finishReason = choice.finish_reason;
          }
          const delta = choice.delta ?? {};
          for (const [field, type] of [
            ["reasoning_content", "reasoning_delta"], ["content", "text_delta"],
          ]) {
            if (delta[field] == null) continue;
            if (typeof delta[field] !== "string") throw new Error("Invalid text delta.");
            if (delta[field]) await sendEvent({ type, text: delta[field] });
          }
        }
      }
      if (!sawDone || finishReason === null) {
        throw new Error("Upstream stream ended before its terminal markers.");
      }
      // Production: persist usage/metadata in a durable server-side ledger here.
      // Receiving an object is not the same as validating and settling a charge.
      await sendEvent({
        type: "done", finish_reason: finishReason, usage,
        usage_status: usage === null ? "missing" : "received",
        provider_response_id: responseId, model: returnedModel,
      });
      res.end();
    } catch (error) {
      if (res.destroyed || res.writableEnded) return;
      const message = timedOut
        ? "Application deadline exceeded."
        : "The DeepSeek stream ended unexpectedly.";
      if (!res.headersSent) {
        res.status(timedOut ? 504 : 502).json({ error: message });
      } else {
        // One bounded terminal write, also usable after the upstream signal aborts.
        res.end(`${JSON.stringify({
          type: "error", code: timedOut ? "deadline_exceeded" : "upstream_stream_error",
          message, finish_reason: finishReason, usage,
        })}\n`);
      }
      console.error("DeepSeek stream failure", {
        error_type: error?.name ?? "Error", provider_response_id: responseId,
      });
    } finally {
      clearTimeout(deadline);
      clearInterval(heartbeat);
      req.removeListener("aborted", abortUpstream);
      res.removeListener("close", abortUpstream);
      controller.abort();
      if (upstream?.body && !upstream.body.locked) {
        try { await upstream.body.cancel(); } catch { /* Already closed. */ }
      }
    }
  });
  return app;
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  createApp({
    apiKey: process.env.DEEPSEEK_API_KEY,
    deadlineMs: Number(process.env.DEEPSEEK_TIMEOUT_MS ?? 0),
  }).listen(3000, "127.0.0.1", () => {
    console.log("Open http://127.0.0.1:3000");
  });
}

The backend emits reasoning_delta, text_delta, a final done message, or an explicit error. A 15-second heartbeat message represents backend liveness only; it does not prove that DeepSeek has started inference. The loop observes Node.js write backpressure by waiting for drain when necessary.

A done message means the upstream protocol reached its terminal markers, not that every finish reason represents a complete answer. Its usage_status is application-defined and only distinguishes received metadata from missing metadata. Persist and validate usage on the server before treating any cost as settled; the browser’s copy is not an accounting source of truth.

X-Accel-Buffering: no can help when Nginx honors that header, but it is not a universal switch. Configure buffering separately in any reverse proxy, serverless platform, CDN, or application server that sits between the backend and browser.

2. Read the stream in the browser

Create a public directory and save this file as public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>DeepSeek streaming demo</title>
  <style>
    body { max-width: 52rem; margin: 2rem auto; padding: 0 1rem; font: 1rem/1.5 system-ui; }
    textarea { display: block; width: 100%; min-height: 6rem; box-sizing: border-box; }
    pre { white-space: pre-wrap; overflow-wrap: anywhere; }
  </style>
</head>
<body>
  <form id="chat-form">
    <label for="prompt">Prompt</label>
    <textarea id="prompt" required>Explain why streamed JSON needs an incremental parser in three concise points.</textarea>
    <button type="submit">Send / replace</button>
    <button id="stop" type="button" disabled>Stop</button>
  </form>
  <p id="status" role="status" aria-live="polite"></p>
  <h2>Answer</h2>
  <pre id="answer"></pre>
  <details>
    <summary>Reasoning received</summary>
    <pre id="reasoning"></pre>
  </details>
  <details>
    <summary>Usage received, not a settled bill</summary>
    <pre id="usage"></pre>
  </details>

  <script type="module">
    const form = document.querySelector("#chat-form");
    const promptInput = document.querySelector("#prompt");
    const stopButton = document.querySelector("#stop");
    const status = document.querySelector("#status");
    const answerNode = document.querySelector("#answer");
    const reasoningNode = document.querySelector("#reasoning");
    const usageNode = document.querySelector("#usage");
    let activeRequest = null;

    function cancelFrame(run) {
      if (run.frameId !== null) cancelAnimationFrame(run.frameId);
      run.frameId = null;
    }

    function flushView(run) {
      cancelFrame(run);
      if (activeRequest !== run) return;
      answerNode.textContent = run.answer;
      reasoningNode.textContent = run.reasoning;
    }

    function receiveEvent(run, event) {
      // A superseded or cancelled request cannot change the current view.
      if (activeRequest !== run || run.controller.signal.aborted) return;
      if (event.usage && typeof event.usage === "object") {
        run.usage = event.usage;
      }
      if (event.type === "text_delta") run.answer += event.text;
      else if (event.type === "reasoning_delta") run.reasoning += event.text;
      else return;

      status.textContent = event.type === "text_delta"
        ? "Receiving answer..." : "Receiving reasoning...";
      if (run.frameId === null) {
        run.frameId = requestAnimationFrame(() => flushView(run));
      }
    }

    function completionLabel(reason) {
      const labels = {
        stop: "Complete.",
        length: "Truncated: an output or context limit was reached.",
        content_filter: "Stopped by a content filter; not a normal complete answer.",
        tool_calls: "Tool action required; this demo does not execute tools.",
        insufficient_system_resource: "Interrupted: provider resources were unavailable.",
        aborted: "Interrupted: generation was aborted upstream.",
      };
      return labels[reason] ?? `Ended with an unrecognized finish reason: ${reason}`;
    }

    async function readNdjson(response, onEvent, signal) {
      if (!response.body) throw new Error("No readable response body.");
      const reader = response.body.getReader();
      const decoder = new TextDecoder("utf-8", { fatal: true });
      const maxLineChars = 1_000_000; // Local demo guard, not a provider limit.
      let buffer = "";
      try {
        while (true) {
          signal.throwIfAborted();
          const { done, value } = await reader.read();
          signal.throwIfAborted();
          buffer += decoder.decode(value, { stream: !done });
          let newline;
          while ((newline = buffer.indexOf("\n")) !== -1) {
            const line = buffer.slice(0, newline);
            buffer = buffer.slice(newline + 1);
            if (line.length > maxLineChars) throw new Error("NDJSON record too large.");
            if (!line.trim()) continue;
            const event = JSON.parse(line);
            if (!event || typeof event !== "object" || Array.isArray(event)) {
              throw new Error("Invalid NDJSON event.");
            }
            if (event.type === "text_delta" || event.type === "reasoning_delta") {
              if (typeof event.text !== "string") throw new Error("Invalid text delta.");
            } else if (event.type === "done") {
              if (typeof event.finish_reason !== "string" || !event.finish_reason) {
                throw new Error("Terminal event has no finish reason.");
              }
              if (event.usage != null &&
                  (typeof event.usage !== "object" || Array.isArray(event.usage))) {
                throw new Error("Invalid usage metadata.");
              }
              onEvent(event);
              return event; // Application terminal event, not transport EOF.
            } else if (event.type === "error") {
              onEvent(event);
              throw new Error(event.message || "The upstream stream failed.");
            } else if (event.type !== "heartbeat") {
              throw new Error("Unknown NDJSON event type.");
            }
            onEvent(event);
          }
          if (buffer.length > maxLineChars) throw new Error("NDJSON record too large.");
          if (done) {
            if (buffer.trim()) throw new Error("Incomplete final NDJSON record.");
            throw new Error("Connection closed before the terminal event.");
          }
        }
      } finally {
        // Also close the body when returning early on a terminal event or error.
        try { await reader.cancel(); } catch { /* Already closed or aborted. */ }
        reader.releaseLock();
      }
    }

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      const prompt = promptInput.value.trim();
      if (!prompt) return;

      if (activeRequest) {
        cancelFrame(activeRequest);
        activeRequest.controller.abort();
      }
      const run = {
        controller: new AbortController(),
        answer: "", reasoning: "", usage: null, frameId: null,
      };
      activeRequest = run;
      stopButton.disabled = false;
      status.textContent = "Connecting / waiting for output...";
      answerNode.textContent = "";
      reasoningNode.textContent = "";
      usageNode.textContent = "Not received yet.";

      try {
        const response = await fetch("/api/deepseek-stream", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ prompt }),
          signal: run.controller.signal,
        });
        if (activeRequest !== run) {
          await response.body?.cancel();
          return;
        }
        run.controller.signal.throwIfAborted();
        if (!response.ok) {
          const details = await response.json().catch(() => ({}));
          throw new Error(details.error ?? `Request failed: HTTP ${response.status}`);
        }
        const mime = response.headers.get("content-type")?.split(";")[0].trim();
        if (mime !== "application/x-ndjson") {
          await response.body?.cancel();
          throw new Error("Expected an NDJSON stream from the backend.");
        }
        const terminal = await readNdjson(
          response, (message) => receiveEvent(run, message), run.controller.signal,
        );
        if (activeRequest !== run) return;
        run.controller.signal.throwIfAborted();
        run.usage = terminal.usage ?? null;
        status.textContent = completionLabel(terminal.finish_reason) +
          (run.usage === null ? " Usage unavailable; billing needs reconciliation." : "");
      } catch (error) {
        if (activeRequest !== run) return;
        status.textContent = run.controller.signal.aborted
          ? "Stopped. Partial text is still visible."
          : `Interrupted: ${error.message}. Partial text is still visible.`;
      } finally {
        // Old requests must not clear the new controller or disable its Stop button.
        if (activeRequest === run) {
          flushView(run);
          usageNode.textContent = run.usage === null
            ? "Unavailable. Do not record a zero cost; reconcile on the server."
            : JSON.stringify(run.usage, null, 2);
          stopButton.disabled = true;
          activeRequest = null;
        } else {
          cancelFrame(run);
        }
      }
    });

    stopButton.addEventListener("click", () => activeRequest?.controller.abort());
  </script>
</body>
</html>

The browser uses textContent, not innerHTML, for model output. This prevents streamed HTML from being interpreted as page markup. If the product later supports Markdown, parse and sanitize it through a security-reviewed renderer rather than assigning generated output directly to innerHTML.

Both output channels are batched through requestAnimationFrame(). Buffers, the controller, and the scheduled frame belong to one request object. A superseded request cannot update the current answer or clear the new controller, and final cleanup flushes the current request’s buffered partial text even after interruption.

The browser requires a valid terminal NDJSON message; EOF alone becomes an interrupted state. The Send / replace action intentionally replaces the previous view. Stop keeps the current partial answer visible. Delayed events and cleanup from the replaced request are ignored.

3. Run the local browser demo

Keep these files together:

deepseek-stream/
  sse-parser.mjs
  server.mjs
  public/
    index.html

After installing Express and setting the server-side API key, start the server:

node server.mjs

Open http://127.0.0.1:3000. Every real submission reaches the billable DeepSeek API. For a configured end-to-end application deadline, set the same DEEPSEEK_TIMEOUT_MS variable before starting the server; leaving it unset adds no application timer. Test abnormal stream endings and rapid replacement with synthetic responses before relying on a live deployment.

Why this example uses fetch() instead of EventSource

The browser’s native EventSource constructor is designed around opening a URL and offers only limited constructor options. It does not provide the normal request-body and custom-header controls needed for this POST-based JSON flow. A streaming fetch() request gives the application a request body, an AbortSignal, access to the response status, and a readable response body.

Know when the stream is actually complete

Do not treat any closed connection as successful completion. A cable failure, process crash, proxy timeout, provider interruption, and normal finish can all cause the reader to stop receiving bytes.

For the Chat Completions wire format, a robust implementation should record:

  • Whether the SDK iterator or raw response ended without throwing.
  • Whether a non-null finish_reason was received.
  • Whether the raw SSE parser observed [DONE].
  • Whether the final usage-bearing chunk arrived.

SDK iterators may hide the literal [DONE] record. The Python and Node.js SDK examples therefore check the final finish reason and report usage, but do not independently prove that the raw marker was observed. Use the raw SSE backend when wire-level termination verification is required.

For the browser example, normal completion requires its application-level done message and a meaningful finish_reason. Do not search for the raw DeepSeek [DONE] string in the NDJSON response. A valid terminal message can arrive before the HTTP reader reports EOF.

finish_reasonMeaningRecommended application state
stopThe model reached a normal stop condition.Mark the answer complete after validating the terminal event; track billing status separately.
lengthThe applicable output or context limit stopped generation.Keep the partial answer and mark it truncated.
content_filterA safety filter stopped or changed completion.Do not present it as a normal complete answer.
tool_callsThe model requested one or more tools.Continue the tool workflow rather than ending the conversation.
insufficient_system_resourceThe request could not complete because sufficient system resources were unavailable.Preserve partial output and consider a controlled retry.
abortedGeneration was aborted.Show a cancelled or interrupted state.

Assemble the final answer without damaging it

Append each non-empty content delta exactly as received:

const answerParts = [];

if (contentDelta) {
  answerParts.push(contentDelta);
  process.stdout.write(contentDelta);
}

const finalAnswer = answerParts.join("");

Do not call trim() on each JavaScript delta or strip() on each Python delta. A chunk may intentionally begin or end with a space, newline, indentation character, or Markdown delimiter.

Also avoid updating both the accumulated string and the rendered element from the same delta through two separate code paths. That common mistake makes text appear twice.

If a stream fails, retain at least these fields:

  • The partial final-answer text.
  • The partial reasoning buffer, if your application intentionally stores it.
  • The last known finish reason, if any.
  • Whether [DONE] was observed.
  • The failure category and timestamp.
  • A request identifier when the provider or client exposes one.

Capture token usage from the final chunk

The examples send:

"stream_options": {
  "include_usage": true
}

Under the current DeepSeek Chat Completions contract, the final JSON chunk before [DONE] contains usage and exactly one choice with a non-null finish reason and no new content. There is no separate usage-only chunk. With include_usage: true, earlier usage fields are null; when it is false or omitted, earlier fields are absent. The final statistics are still supplied. Save the last non-null usage value without assuming another provider’s empty-choice convention.

If the connection is cancelled or fails before the final chunk, usage may be unavailable. Do not estimate it by counting text fragments, and do not turn missing data into zero tokens or zero cost. Receiving a usage object also does not validate all its fields: accounting must check the required values and their consistency.

Keep answer completion separate from billing reconciliation. For example, a locally recorded result could have the following state after a normal answer but missing usage. These are application fields, not additional DeepSeek response fields:

{
  "transport_status": "complete",
  "answer_status": "complete",
  "usage_status": "missing",
  "billing_status": "pending_reconciliation",
  "calculated_cost_usd": null
}

Useful operational fields include the model, request start time, time until the first displayed answer delta, total request duration, final finish reason, and usage object. Avoid logging API keys, authorization headers, complete prompts, reasoning output, or user data by default.

Streaming does not establish a different billing unit. Use the token totals returned by the API and the current official DeepSeek models and pricing page rather than estimating cost from the number of chunks.

For validating returned usage and maintaining a cost ledger without false zeroes, use our DeepSeek API billing guide. The display examples here do not calculate or settle charges.

Set timeouts for the correct stage

“The request timed out” is not specific enough for a production streaming system. Track separate stages when your HTTP stack allows it:

Timeout or deadlineWhat it measuresImportant streaming detail
Connection timeoutOpening the network connection.Usually short relative to generation, but must account for your network path and proxy.
Response-header timeoutWaiting for the HTTP status and headers.A failure here occurs before the stream is available.
First-event timeoutWaiting for any SSE event or comment.A DeepSeek keep-alive comment counts as transport activity.
First-content deadlineWaiting for the first reasoning or answer delta.A keep-alive comment should not be mistaken for model output.
Idle timeoutMaximum permitted period without stream activity.Reset it for valid SSE activity, including comments, according to your policy.
Overall deadlineTotal permitted request duration.Do not make it so short that valid thinking requests are routinely terminated.

DeepSeek documents that a streaming request may receive : keep-alive SSE comments while waiting. If inference has not started after 10 minutes, DeepSeek closes the connection. Your parser must ignore those comments as output while still recognizing that the transport is active.

The Python and JavaScript SDK documentation lists a 10-minute default request timeout and two automatic retries for selected failures. These settings are not a universal end-to-end deadline for every HTTP stack or streaming stage. The Node.js examples use their own optional timer across both opening and consumption; the Python example retains SDK timeout behavior rather than implementing a separate whole-operation watchdog. Both disable automatic SDK retries.

Cancel generation and close the connection correctly

Cancellation should propagate from the interface to its backend and upstream HTTP request, not merely hide a loading indicator. Closing the connection does not prove that the provider instantly stopped all processing, and it does not make already processed work free. Retain any received usage and reconcile missing usage rather than promising a zero-cost cancellation.

  • Python: Exit or close the stream when the user cancels. The context managers in the Python example clean up after Ctrl+C or an exception.
  • Node.js SDK: Pass an AbortSignal in the request options. The current SDK supports cancellation while the response body is being read.
  • Browser: Call AbortController.abort() on the active fetch() request.
  • Backend proxy: Listen for the browser connection closing and abort the upstream DeepSeek request with a second controller.

After cancellation, retain the partial answer as an interrupted result. Do not mark it complete merely because the HTTP connection is now closed.

Handle errors before and during the stream

Failures before the response opens are normal HTTP errors. Failures after a successful status require different handling because the user may already have seen part of the answer.

Errors before streaming begins

StatusCurrent DeepSeek meaningRetry guidance
400Invalid request-body format.Do not retry unchanged. Correct serialization or request structure.
401Authentication failed.Do not retry unchanged. Check the API key and authorization header.
402Insufficient balance.Do not retry until the account balance is resolved.
422Invalid request parameters.Do not retry unchanged. Correct the field or value identified by the response.
429Rate or concurrency limit reached.Retry only with capped exponential backoff and jitter, or queue the request.
500DeepSeek server error.A limited retry may be appropriate if no output was shown.
503Server overloaded.A limited delayed retry may be appropriate if no output was shown.

Read the current DeepSeek error-code documentation rather than copying status meanings from another compatible provider. Our API error-code troubleshooting guide covers diagnosis beyond stream handling.

Errors after the stream has opened

After an HTTP success response, the stream can still fail because of:

  • A connection reset or network interruption.
  • An idle or overall timeout.
  • A malformed or incomplete SSE event.
  • An unexpected end of file without [DONE].
  • A user or server AbortSignal.
  • A client closing the browser tab or navigating away.
  • A non-success finish reason such as length, content_filter, insufficient_system_resource, or aborted.

For a network, parser, or server failure, preserve partial output and show an explicit interrupted state. Separately, a well-formed stream can terminate with length or another non-success finish reason: that is not necessarily a broken connection. Do not automatically submit the same prompt and append a new generation to existing text.

The Chat Completions documentation checked for this guide does not define a resume token for continuing the same generation from its last interrupted byte or delta. Treat a replacement request as a new generation; a new request is not evidence of resumed computation.

A practical retry policy

  • Retry only transient classes such as selected connection failures, 429, 500, or 503.
  • Use exponential backoff with random jitter and a small retry cap.
  • Honor provider retry guidance when a response includes it.
  • Do not retry invalid requests, bad credentials, insufficient balance, or invalid parameters until the cause changes.
  • Retry automatically only when no user-visible delta has been emitted, unless your product has a deliberate replacement workflow.
  • Apply global concurrency controls so retries do not amplify an outage into a retry storm.

For account-level pacing and concurrency rather than parser behavior, follow the DeepSeek API rate-limit guide.

Troubleshoot common DeepSeek streaming problems

ProblemLikely causeFix
The entire answer appears at oncecURL, application, reverse proxy, CDN, compression layer, or hosting platform is buffering output.Use cURL -N, flush response headers, stream each downstream write, and disable intermediary buffering where supported.
JSON.parse() fails randomlyA network read was treated as a complete SSE event.Keep a persistent buffer and parse only after reaching an SSE blank-line boundary.
The output contains None or undefinedOptional delta fields were appended without checking them.Append only non-null string values.
Reasoning appears, but the answer does notThe code stops after reasoning or uses an incorrect branch.Continue reading until the final finish reason and check both reasoning and content fields independently.
Words run togetherEach delta is being trimmed.Append deltas exactly as received and trim only a completed value when the product genuinely requires it.
The answer is duplicatedThe same delta is rendered twice, or a retry was appended after partial output.Use one rendering path and disable blind mid-stream retries.
The stream closes without [DONE]The connection was interrupted, timed out, or terminated upstream.Mark the result interrupted, preserve partial output, and do not infer success from EOF.
Usage is always missingThe request ended before the final chunk, the code ignores metadata-only chunks, or the wrong API protocol is being parsed.Send include_usage, inspect every chunk, and confirm that the code targets Chat Completions rather than Responses events.
: keep-alive causes a JSON errorSSE comment lines are being passed to the JSON parser.Ignore lines beginning with a colon.
The Stop button only stops screen updatesThe browser abort is not propagated to the backend and upstream request.Abort browser fetch, detect the closed downstream connection, and abort DeepSeek with a server-side controller.
The API key appears in browser developer toolsThe browser is calling DeepSeek directly.Move authentication and the provider request to your backend.
Valid requests end too earlyA short universal timeout was applied to a long-lived thinking stream.Separate connection, first-event, idle, first-content, and overall deadlines.
The connection closes but the UI still says StreamingThe browser accepts EOF without a terminal application event.Require the backend’s done message; otherwise mark interruption and flush buffered text.
A second request disables its own Stop buttonThe first request’s delayed finally block clears shared state.Use request-scoped controllers and buffers; only the current request can update controls or clear the active reference.

Production checklist

  • Store the DeepSeek API key only on the server.
  • Verify the model ID and endpoint against current official documentation.
  • Enable streaming explicitly with stream: true.
  • Null-check every optional chunk field.
  • Keep reasoning and final-answer buffers separate.
  • Preserve spaces, newlines, and Unicode across delta boundaries.
  • Do not equate transport reads, SSE events, chunks, deltas, and tokens.
  • Ignore SSE comments, including : keep-alive.
  • Check [DONE] before JSON parsing in raw Chat Completions streams.
  • Capture the final finish reason and usage object.
  • Mark length, filtering, aborts, and resource failures distinctly.
  • Propagate browser cancellation to the upstream DeepSeek request.
  • Distinguish HTTP errors before streaming from failures after partial output.
  • Disable or deliberately control SDK retries for user-visible streams.
  • Prevent reverse proxies and hosting platforms from buffering the response.
  • Render generated text through textContent or a sanitized Markdown pipeline.
  • Preserve partial output when a stream is interrupted.
  • Exclude secrets and sensitive prompt or output content from default logs.
  • Add application authentication, authorization, rate limits, input limits, and abuse controls to any public streaming endpoint.
  • Keep request ownership explicit so stale callbacks cannot alter a newer request.
  • Flush partial display buffers on cancellation or failure, and keep missing usage pending for reconciliation.

DeepSeek API streaming FAQ

Does DeepSeek support streaming responses?

Yes. For Chat Completions, send "stream": true. DeepSeek returns partial message deltas as data-only Server-Sent Events and terminates the wire stream with data: [DONE].

Does streaming cost more than a non-streaming request?

Streaming changes how the response is delivered, not the number of generated tokens by definition. Do not assume a streaming surcharge or discount. Calculate cost from the returned usage values and DeepSeek’s current model pricing.

Can an interrupted DeepSeek stream resume from the last chunk?

The current Chat Completions documentation does not define a resume cursor for continuing the same generation from an interrupted delta. Preserve the partial answer, label it interrupted, and treat a replacement request as a new generation.

Can a browser stream directly from DeepSeek?

A browser may be technically capable of reading a streamed HTTP body, but placing a secret DeepSeek API key in frontend code exposes it to users. Send the request through an authenticated backend and stream a safe downstream format to the browser.

Why does the response sometimes arrive all at once?

The most common cause is buffering in cURL, your application server, a reverse proxy, a CDN, compression middleware, or the hosting platform. Confirm the raw provider stream with curl -N, then inspect each intermediary between DeepSeek and the interface.

Sources checked

The following primary sources were checked on October 16, 2026. Local regression checks cover synthetic stream framing, backend-handler state and browser interaction; they are not live DeepSeek API billing, latency, or availability tests.