A hack to fetch a URL synchronously in Svelte (Astro)

Svelte doesn't currently have top level await, meaning that in a server-side rendering context, it is impossible to, say, wait a fetch to finish before sending it to the client. There is the #await block, but I want the wait to happen on the server. (I don't care about the full page load taking a little longer as my use case is easily cacheable and already cached on Cloudflare.)

  export let tl = "Guá ū tsi̍t-ē mī-kiānn bē hōo lí khuànn";
  async function toPOJ(text) {
    const response = await fetch("https://pojtl.kemdict.com/toPOJ", {
      method: "POST",
      body: text,
    return response.text();

  // const result = await toPOJ(tl);
  //   -> error as Svelte doesn't have top level await

  {#await toPOJ(tl)}
  {:then value}
    <!-- Waited on the client side -->

There are a few options for me here:

  • Since I'm using Astro, it is relatively easy to just switch to, say, React or .astro for this specific component. But in my use case this would require porting a large number of components.
  • I could also just bite the bullet and allow the value to be waited on the client side.

Or, well… there's a hack that doesn't require porting and can still wait for the fetch on the server side. The downside is just that it's admittedly stupid.

One other point I haven't mentioned is that I'm deploying the application in SSR mode to Node, so I have access to Node builtins. Including node:child_process, which has the execSync family, which will run a process, wait for it to finish, and return its output.

So the hack is to run a child process (shelling out, though I'm not going through a shell), and have that child process do the actual network request. The child process can be a call to curl or literally any other program, but I'm going to use Node because it's more familiar.

This is the script I ended up with:

// hack/postSync.mjs
 ,* @file POST some stuff to a URL.
 ,* Usage: one of
 ,*   echo input | node postSync.mjs <url>
 ,*   node postSync.mjs <url> <input>

// The argument count would break if called as a standalone script.
const url = process.argv[2] || process.exit(1);

async function do_it(text) {
  const response = await fetch(url, {
    method: "POST",
    body: text,
  console.log(await response.text());

// Support both reading from stdin and reading from the second argument
// Because I'm not sure about the length limit of an argument
let text = "";
if (process.argv.length > 3) {
  text = process.argv[3];
} else {
  process.stdin.on("readable", () => {
    let chunk;
    while (null !== (chunk = process.stdin.read())) {
      text += chunk;
  process.stdin.on("end", () => {

And I'm not even bothering with a top level await in this script, because Node will only exit when the async function finishes.

It's then called like this:

import { spawnSync } from "node:child_process";

export function toPOJ(text) {
  return spawnSync(
    ["hack/postSync.mjs", "https://pojtl.kemdict.com/toPOJ"],
    { input: text }

This function, from the point of view of the main application, is just a normal non-async function that happens to block a little bit. If there's a need to, say, fetch multiple URLs, I can also modify the postSync script to accept multiple URLs and await them all at once there.

This hopefully isn't too much debt to take on, and is probably a reasonable stopgap solution until Svelte maybe eventually gets support for top level await.