Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[2.0 rc.9] renderToString: an async memo that rejects after the render is an unhandled rejection and exits Node

Closed
#3,570 1 comment 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
Half a day
Newbie friendliness
76/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
node.js, typescript
Domain
backend, web-dev

Research direction

Start in packages/solid/src/server/signals.ts, especially createDeferredPromise and the deferred rejection paths around lines 741, 760, and 1299-1324. Run the supplied repro.mjs commands from packages/web against a built checkout, then verify that all renderToString variants remain alive after 50 ms and exit with code 0 without changing the existing stream behavior.

Written by the indexing model from the issue text.

Description

Describe the bug

renderToString is synchronous, so an async memo created during the render is still in flight when the HTML is returned. If that promise later rejects, the rejection has no handler, and with Node's default --unhandled-rejections=throw the server process exits with code 1.

It happens when the memo is read under <Loading>, when it is read under <Errored> around <Loading>, and when it is never read at all, so there is no boundary the app can add to prevent it. The same tree rendered with renderToStream is fine.

const App = () => {
  const data = createMemo(async () => {
    await new Promise(r => setTimeout(r, 5));
    throw new Error("fetch failed");
  });
  return (
    <Errored fallback="error">
      <Loading fallback="loading">{data()}</Loading>
    </Errored>
  );
};

const html = renderToString(() => <App />); // returns the fallback HTML
// about 5 ms later: "Error: fetch failed" is an unhandled rejection, exit code 1

A data fetch that fails (network error, a 500 from an upstream API) is an ordinary event. Here it takes the whole server down a few milliseconds after the response was already produced, and every other request in flight goes with it.

Your Example Website or App

Self-contained script below. It has no JSX, so it runs with plain node against the built packages.

Steps to Reproduce the Bug or Issue
// repro.mjs — run from packages/web of a built checkout
import { createComponent, createMemo, Errored, Loading } from "solid-js";
import { renderToString, renderToStream } from "@solidjs/web";

const [mode, variant] = process.argv.slice(2);
const App = () => {
  const data = createMemo(async () => {
    await new Promise(r => setTimeout(r, 5));
    throw new Error("fetch failed");
  });
  const loading = () => createComponent(Loading, { fallback: "loading", get children() { return data(); } });
  if (variant === "unread") return "static";
  if (variant === "loading") return loading();
  return createComponent(Errored, { fallback: "error", get children() { return loading(); } });
};

const html = mode === "string" ? renderToString(App) : await renderToStream(App, { onError() {} });
console.log("html:", html.replace(/<script[\s\S]*?<\/script>/g, "").slice(0, 60));
setTimeout(() => console.log("still alive after 50 ms"), 50);
Command Output Exit code
node repro.mjs string unread html: static, then Error: fetch failed 1
node repro.mjs string loading html: loading, then Error: fetch failed 1
node repro.mjs string errored+loading html: loading, then Error: fetch failed 1
node repro.mjs stream unread html: static, still alive after 50 ms 0
node repro.mjs stream errored+loading still alive after 50 ms 0

The results are the same with node --conditions=development. (stream loading, with no <Errored>, fails for a different reason: #3569.)

Expected behavior

renderToString returns the fallback HTML and the process keeps running. The late rejection is either dropped (the client fetches again after hydration) or routed to the server error hook, but it is not left unhandled.

Platform
  • solid-js / @solidjs/web 2.0.0-rc.9 (next @ 37fd1e67), production and development server builds
  • Node 24.21, macOS arm64
Additional context

In packages/solid/src/server/signals.ts a memo's deferred.promise is only observed when the render serializes it: serializes = !!(ctx?.async && ctx.serialize && id && !noHydrate), then if (serializes) ctx.serialize(id, deferred.promise, deferStream) (lines 1320-1324). Under renderToString, ctx.async is false, so nothing ever attaches a handler to that promise. When the user's promise rejects, settleServerAsync calls deferred.reject(error) (line 760, and line 741 for a synchronous throw on a rerun), and that rejection is unhandled.

The same file already covers the equivalent case for an abandoned duplicate flight with (result as any).then(undefined, () => {}) (around line 1299), with a comment about --unhandled-rejections=strict.

As a check, I ran the script against a copy of packages/solid/dist/server.js in which createDeferredPromise adds promise.then(undefined, () => {}) right after it creates the promise. All three string rows then print still alive after 50 ms and exit with 0, and the stream errored+loading row is unchanged. Marking the rejection as observed there does not stop the real consumers (ctx.serialize, the pending-retry subscriber) from seeing it.

Dominant language
TypeScript
Stars
36.1k
Forks
1.1k
Avg merge
9h 56m
Merged PRs (30d)
268

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from solidjs/solid

All issues in solidjs/solid

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.