FrontendAtlas
Interview Questions
Home>JavaScript interview questions> JavaScript coding challenges >Stream to Text (ReadableStream -> string)

Stream to Text (ReadableStream -> string)

intermediate
By FrontendAtlas Editorial · Updated Jul 14, 2026
Implement streamToText(stream, options) that reads a ReadableStream<Uint8Array> and returns the full string. Use TextDecoder with stream=true to handle multi-byte characters across chunk boundaries. Optionally support AbortSignal to cancel reading. Concepts: Streams API, TextDecoder, async iteration.

Arguments

  • stream: ReadableStream<Uint8Array> — A stream of byte chunks (e.g., from fetch(...).body).
  • options: { encoding?: string, signal?: AbortSignal } — Optional text encoding and AbortSignal for cancellation.

Returns

Promise<string> — Resolves to the decoded text, or rejects on abort.
Examples
const res = await fetch('/api/logs');
const text = await streamToText(res.body);

const ac = new AbortController();
const p = streamToText(stream, { signal: ac.signal });
ac.abort();

Solution

Overview

Get a reader, then put signal setup—including the already-aborted path—and the read loop inside one try/finally. Decode chunks with TextDecoder using { stream: true } so multi-byte characters are not split, flush at the end, and cancel on abort. The finally block always removes the abort listener and releases the reader lock on success, failure, or cancellation.

1

Approach: Reader loop + TextDecoder

Steps:

1) Validate stream and getReader().

2) Create TextDecoder with optional encoding.

3) Enter try/finally before handling an already-aborted signal or installing its listener.

4) Read in a loop: decode each chunk with { stream: true } and append.

5) When done, flush decoder and append final text.

6) If the signal aborts, cancel the reader and throw AbortError.

7) In finally, remove the listener and call reader.releaseLock() when available on every path.

Complexity: O(n) time and O(n) memory for the final string.

function toAbortError(reason, message = 'Aborted') {
  if (reason && reason.name === 'AbortError') return reason;
  const err = new Error(reason && reason.message ? reason.message : message);
  try { err.name = 'AbortError'; } catch { try { Object.defineProperty(err, 'name', { value: 'AbortError' }); } catch {} }
  return err;
}

export default async function streamToText(stream, options = {}) {
  if (!stream || typeof stream.getReader !== 'function') {
    throw new TypeError('stream must be a ReadableStream');
  }

  const { encoding = 'utf-8', signal } = options;
  const reader = stream.getReader();
  let output = '';
  let aborted = false;

  const cancelReader = () => {
    if (!reader.cancel) return;
    try {
      const cancellation = reader.cancel();
      if (cancellation && typeof cancellation.catch === 'function') {
        cancellation.catch(() => {});
      }
    } catch {
      // Cancellation is best effort; the AbortError still wins.
    }
  };

  const onAbort = () => {
    aborted = true;
    cancelReader();
  };

  try {
    const decoder = new TextDecoder(encoding);

    if (signal) {
      if (signal.aborted) {
        aborted = true;
        cancelReader();
        throw toAbortError(signal.reason);
      }
      signal.addEventListener('abort', onAbort, { once: true });
    }

    while (true) {
      if (aborted) throw toAbortError(signal && signal.reason);
      const { done, value } = await reader.read();
      if (aborted) throw toAbortError(signal && signal.reason);
      if (done) break;
      if (value) output += decoder.decode(value, { stream: true });
    }
    output += decoder.decode();
    return output;
  } finally {
    if (signal) signal.removeEventListener('abort', onAbort);
    if (typeof reader.releaseLock === 'function') reader.releaseLock();
  }
}
function toAbortError(reason: any, message: string = 'Aborted'): Error {
  if (reason && reason.name === 'AbortError') return reason;
  const err = new Error(reason && reason.message ? reason.message : message);
  try { (err as any).name = 'AbortError'; } catch { try { Object.defineProperty(err as any, 'name', { value: 'AbortError' }); } catch {} }
  return err;
}

type StreamToTextOptions = {
  encoding?: string;
  signal?: AbortSignal;
};

type ByteReader = {
  read: () => Promise<{ done: boolean; value?: Uint8Array }>;
  cancel?: () => void | Promise<void>;
  releaseLock?: () => void;
};

export default async function streamToText(
  stream: { getReader: () => ByteReader },
  options: StreamToTextOptions = {}
): Promise<string> {
  if (!stream || typeof stream.getReader !== 'function') {
    throw new TypeError('stream must be a ReadableStream');
  }

  const { encoding = 'utf-8', signal } = options;
  const reader = stream.getReader();
  let output = '';
  let aborted = false;

  const cancelReader = () => {
    if (!reader.cancel) return;
    try {
      const cancellation = reader.cancel();
      if (cancellation && typeof (cancellation as Promise<void>).catch === 'function') {
        (cancellation as Promise<void>).catch(() => {});
      }
    } catch {
      // Cancellation is best effort; the AbortError still wins.
    }
  };

  const onAbort = () => {
    aborted = true;
    cancelReader();
  };

  try {
    const decoder = new TextDecoder(encoding);

    if (signal) {
      if (signal.aborted) {
        aborted = true;
        cancelReader();
        throw toAbortError((signal as any).reason);
      }
      signal.addEventListener('abort', onAbort, { once: true });
    }

    while (true) {
      if (aborted) throw toAbortError(signal && (signal as any).reason);
      const { done, value } = await reader.read();
      if (aborted) throw toAbortError(signal && (signal as any).reason);
      if (done) break;
      if (value) output += decoder.decode(value, { stream: true });
    }
    output += decoder.decode();
    return output;
  } finally {
    if (signal) signal.removeEventListener('abort', onAbort);
    if (typeof reader.releaseLock === 'function') reader.releaseLock();
  }
}

Notes & Pitfalls

Pitfalls
  • Calling TextDecoder without { stream: true } can corrupt multi-byte characters.
  • Not flushing the decoder at the end may drop trailing bytes.
  • Ignoring abort signals can leave readers open or stuck.
  • Returning or throwing without `reader.releaseLock()` retains exclusive ownership of the stream.
Edge cases
  • Empty stream should resolve to an empty string.
  • Split emoji or accented chars across chunks should decode correctly.
  • An already-aborted signal rejects immediately but still runs reader cleanup.
  • Success, read failure, and abort all release the reader lock.
Techniques
  • ReadableStream reader loop.
  • TextDecoder streaming decode.
  • AbortSignal cancellation.
  • try/finally cleanup for listener removal and reader lock release.

Resources

  • MDN – Streams API
  • MDN – TextDecoder

Similar questions

Fetch JSON with Timeout + AbortintermediateTake Latest (Abort Previous Requests)intermediatePoll Until (with Timeout + Abort)intermediate

Guides

Frontend interview preparation guideGuideFrontend coding interview questions and prep guideBlueprintJavaScript Problems That Actually Show UpBlueprintBuild Great UI in 60 MinutesBlueprint

Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.

Open frontend interview questionsBrowse JavaScript interview questionsOpen Essential 60Open Machine Coding HubOpen Frontend Coding ChallengesOpen System DesignOpen Interview Prep GuideOpen System Design BlueprintOpen Framework Prep PathsOpen Study PlansOpen JavaScript mastery study planOpen Company PrepOpen JavaScript Framework Prep Guide
↗Incidents hub
← Prev←70 / 88