Skip to content

Error Handling

Diátaxis type: How-to Guide + Reference — problem-oriented patterns for handling errors at every layer, with reference tables for error codes and error classes.

Scratchy provides a layered error handling architecture that spans the full stack — from database errors through API responses to client-side error boundaries. Every layer produces structured, type-safe errors with consistent formatting.

Architecture Overview

┌──────────────────────────────────────────────────────────┐
│                    Client (Qwik)                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Global Error Boundary (global-error.tsx)          │  │
│  │  ┌──────────────────────────────────────────────┐  │  │
│  │  │  Layout Error Boundary                       │  │  │
│  │  │  ┌────────────────────────────────────────┐  │  │  │
│  │  │  │  Route Error Boundary (error.tsx)      │  │  │  │
│  │  │  │  ┌──────────────────────────────────┐  │  │  │  │
│  │  │  │  │  Component Error Boundaries      │  │  │  │  │
│  │  │  │  └──────────────────────────────────┘  │  │  │  │
│  │  │  └────────────────────────────────────────┘  │  │  │
│  │  └──────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────┬───────────────────────────────────┘
                       │ tRPC / REST
┌──────────────────────▼───────────────────────────────────┐
│                  Server (Fastify)                         │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────────┐ │
│  │  tRPC Error  │  │  Fastify     │  │  Zod Validation │ │
│  │  Handler     │  │  Error       │  │  Errors         │ │
│  │              │  │  Handler     │  │                 │ │
│  └──────┬──────┘  └──────┬───────┘  └────────┬────────┘ │
│         └────────────────┼────────────────────┘          │
│                          ▼                               │
│              Structured Error Response                   │
│                          │                               │
│  ┌───────────────────────┼───────────────────────────┐   │
│  │                       ▼                           │   │
│  │  ┌─────────────┐  ┌──────────┐  ┌─────────────┐  │   │
│  │  │  Database    │  │  Worker   │  │  External   │  │   │
│  │  │  (Drizzle)   │  │  (Piscina)│  │  Services   │  │   │
│  │  └─────────────┘  └──────────┘  └─────────────┘  │   │
│  └───────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────┘

Errors propagate upward through these layers. Each layer catches, enriches, and re-throws or responds with structured error data. The guiding principles:

  1. Errors are values — use typed error classes, not bare strings.
  2. Structured everywhere — every error carries a code, message, and optional metadata.
  3. Fail loudly in development, gracefully in production — show full stack traces in dev, user-friendly messages in prod.
  4. Log at the boundary — log errors where they are caught, not where they are thrown.

Error Utilities

createError() — Structured Error Factory

A Nuxt-inspired utility for creating structured errors anywhere in the stack. Use it in server code, route loaders, actions, and API handlers.

typescript
// lib/errors.ts
import type { StatusCode } from "~/types/http.js";

interface ErrorOptions {
  statusCode: StatusCode;
  message: string;
  code?: string;
  fatal?: boolean;
  data?: Record<string, unknown>;
  cause?: unknown;
}

export class AppError extends Error {
  readonly statusCode: StatusCode;
  readonly code: string;
  readonly fatal: boolean;
  readonly data: Record<string, unknown>;

  constructor(options: ErrorOptions) {
    super(options.message, { cause: options.cause });
    this.name = "AppError";
    this.statusCode = options.statusCode;
    this.code = options.code ?? `E_HTTP_${options.statusCode}`;
    this.fatal = options.fatal ?? false;
    this.data = options.data ?? {};
  }

  toJSON() {
    return {
      error: {
        code: this.code,
        message: this.message,
        statusCode: this.statusCode,
        data: Object.keys(this.data).length > 0 ? this.data : undefined,
      },
    };
  }
}

export function createError(options: ErrorOptions): AppError {
  return new AppError(options);
}

// Type guard
export function isAppError(value: unknown): value is AppError {
  return value instanceof AppError;
}

Usage:

typescript
import { createError } from "~/lib/errors.js";

// In a route loader or action
throw createError({
  statusCode: 404,
  message: "Course not found",
  code: "COURSE_NOT_FOUND",
  data: { courseId: "abc123" },
});

// Fatal errors bypass error boundaries and show the global error page
throw createError({
  statusCode: 500,
  message: "Database connection lost",
  fatal: true,
});

ErrorResponse — HTTP Error Wrapper

A RedwoodJS-inspired class for returning HTTP error responses from server functions without throwing. Useful when a non-200 response is an expected outcome, not an exceptional condition.

typescript
// lib/error-response.ts
import type { StatusCode } from "~/types/http.js";

export class ErrorResponse {
  readonly status: StatusCode;
  readonly message: string;
  readonly headers: Record<string, string>;

  constructor(
    status: StatusCode,
    message: string,
    headers: Record<string, string> = {},
  ) {
    this.status = status;
    this.message = message;
    this.headers = headers;
  }

  toJSON() {
    return {
      error: {
        statusCode: this.status,
        message: this.message,
      },
    };
  }
}

export function isErrorResponse(value: unknown): value is ErrorResponse {
  return value instanceof ErrorResponse;
}

Usage in a Fastify route:

typescript
import { ErrorResponse, isErrorResponse } from "~/lib/error-response.js";

fastify.get("/external/api/v1/license/:key", async (request, reply) => {
  const result = validateLicenseKey(request.params.key);

  if (isErrorResponse(result)) {
    return reply
      .status(result.status)
      .headers(result.headers)
      .send(result.toJSON());
  }

  return result;
});

function validateLicenseKey(key: string): LicenseData | ErrorResponse {
  if (!key) {
    return new ErrorResponse(400, "License key is required");
  }
  if (key.length !== 32) {
    return new ErrorResponse(422, "Invalid license key format");
  }
  // ... validation logic
  return { valid: true, expiresAt: "2026-01-01" };
}

notFound() — Not-Found Helper

A helper that throws a standardized 404 error, used in route loaders and server functions:

typescript
// lib/not-found.ts
import { createError } from "~/lib/errors.js";

export function notFound(message = "Not found"): never {
  throw createError({
    statusCode: 404,
    message,
    code: "NOT_FOUND",
  });
}

Usage:

typescript
import { notFound } from "~/lib/not-found.js";

export const useProductLoader = routeLoader$(async ({ params }) => {
  const product = await findProductById.execute({ id: params.id });
  if (!product[0]) {
    notFound(`Product ${params.id} not found`);
  }
  return product[0];
});

Client-Side Error Handling

Error Boundary Components

Scratchy uses Qwik's error boundary pattern, organized by route segment. Error boundaries catch rendering errors and display fallback UI without crashing the entire page.

Component-Level Error Boundary

Use Qwik's ErrorBoundary to wrap components that may fail:

tsx
// components/error-boundary.tsx
import { $, Slot, component$, useSignal } from "@builder.io/qwik";

interface ErrorBoundaryProps {
  fallback?: (error: unknown, reset: () => void) => JSXOutput;
}

export const ErrorBoundary = component$<ErrorBoundaryProps>(({ fallback }) => {
  const error = useSignal<unknown>(null);
  const key = useSignal(0);

  const reset = $(() => {
    error.value = null;
    key.value++;
  });

  if (error.value) {
    if (fallback) {
      return fallback(error.value, reset);
    }

    return (
      <div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950">
        <h3 class="text-sm font-medium text-red-800 dark:text-red-200">
          Something went wrong
        </h3>
        <button
          onClick$={reset}
          class="mt-2 text-sm text-red-600 underline hover:text-red-500"
        >
          Try again
        </button>
      </div>
    );
  }

  return (
    <div key={key.value}>
      <Slot />
    </div>
  );
});

Usage in a page:

tsx
import { component$ } from "@builder.io/qwik";
import { ErrorBoundary } from "~/components/error-boundary";
import { UserProfile } from "~/components/user-profile";

export default component$(() => {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary>
        <UserProfile />
      </ErrorBoundary>
    </div>
  );
});

Route-Level Error Pages

Following the Next.js/Qwik City pattern, every route segment can include an error.tsx file that acts as the error boundary for that segment:

src/client/routes/
├── error.tsx                  # Catches errors from all nested routes
├── global-error.tsx           # Catches root layout errors (last resort)
├── not-found.tsx              # Global 404 page
├── layout.tsx
├── index.tsx
├── blog/
│   ├── error.tsx              # Catches errors in /blog and children
│   ├── index.tsx
│   └── [slug]/
│       ├── error.tsx          # Catches errors in /blog/:slug
│       └── index.tsx
└── dashboard/
    ├── error.tsx              # Catches errors in /dashboard
    ├── layout.tsx
    └── settings/
        ├── error.tsx          # Catches errors in /dashboard/settings
        └── index.tsx

error.tsx — Route Error Boundary

tsx
// routes/blog/[slug]/error.tsx
import { component$ } from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import type { ErrorBoundaryProps } from "~/types/errors";

export default component$<ErrorBoundaryProps>(({ error }) => {
  const loc = useLocation();

  // Check for specific error types
  if (isRouteErrorResponse(error) && error.statusCode === 404) {
    return (
      <div class="mx-auto max-w-2xl px-4 py-16 text-center">
        <h1 class="text-4xl font-bold text-gray-900 dark:text-white">
          Post Not Found
        </h1>
        <p class="mt-4 text-gray-600 dark:text-gray-400">
          The blog post at "{loc.url.pathname}" doesn't exist.
        </p>
        <a
          href="/blog"
          class="bg-primary-600 hover:bg-primary-700 mt-6 inline-block rounded-lg px-4 py-2 text-white"
        >
          Back to Blog
        </a>
      </div>
    );
  }

  return (
    <div class="mx-auto max-w-2xl px-4 py-16 text-center">
      <h1 class="text-4xl font-bold text-gray-900 dark:text-white">
        Error Loading Post
      </h1>
      <p class="mt-4 text-gray-600 dark:text-gray-400">
        Something went wrong while loading this blog post. Please try again.
      </p>
    </div>
  );
});

global-error.tsx — Root Error Fallback

Catches errors in the root layout itself. This page renders its own <html> wrapper since the root layout may have failed:

tsx
// routes/global-error.tsx
import { component$ } from "@builder.io/qwik";
import type { ErrorBoundaryProps } from "~/types/errors";

export default component$<ErrorBoundaryProps>(({ error, reset }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <title>Application Error</title>
      </head>
      <body class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
        <div class="text-center">
          <h1 class="text-6xl font-bold text-gray-900 dark:text-white">500</h1>
          <p class="mt-4 text-lg text-gray-600 dark:text-gray-400">
            A critical error occurred. Please try refreshing the page.
          </p>
          <button
            onClick$={reset}
            class="bg-primary-600 hover:bg-primary-700 mt-6 rounded-lg px-6 py-3 text-white"
          >
            Refresh
          </button>
        </div>
      </body>
    </html>
  );
});

not-found.tsx — 404 Page

tsx
// routes/not-found.tsx
import { component$ } from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";

export default component$(() => {
  const loc = useLocation();

  return (
    <div class="mx-auto max-w-2xl px-4 py-16 text-center">
      <h1 class="text-8xl font-bold text-gray-200 dark:text-gray-700">404</h1>
      <h2 class="mt-4 text-2xl font-semibold text-gray-900 dark:text-white">
        Page Not Found
      </h2>
      <p class="mt-2 text-gray-600 dark:text-gray-400">
        The page "{loc.url.pathname}" doesn't exist.
      </p>
      <a
        href="/"
        class="bg-primary-600 hover:bg-primary-700 mt-8 inline-block rounded-lg px-6 py-3 text-white"
      >
        Go Home
      </a>
    </div>
  );
});

isRouteErrorResponse() — Error Type Guard

A Remix-inspired type guard for identifying error responses from route loaders and actions:

typescript
// lib/route-error.ts
interface RouteErrorResponse {
  statusCode: number;
  message: string;
  code?: string;
  data?: Record<string, unknown>;
}

export function isRouteErrorResponse(
  value: unknown,
): value is RouteErrorResponse {
  return (
    typeof value === "object" &&
    value !== null &&
    "statusCode" in value &&
    "message" in value &&
    typeof (value as RouteErrorResponse).statusCode === "number" &&
    typeof (value as RouteErrorResponse).message === "string"
  );
}

Action Failure Handling

Use Qwik City's action.fail() pattern to return validation errors from form actions without throwing:

tsx
// routes/contact/index.tsx
import { component$ } from "@builder.io/qwik";
import { Form, routeAction$, z, zod$ } from "@builder.io/qwik-city";

export const useContactAction = routeAction$(
  async (data, { fail }) => {
    const result = await sendContactEmail(data);

    if (!result.success) {
      return fail(500, {
        message: "Failed to send email. Please try again later.",
        fieldErrors: {},
      });
    }

    return { success: true };
  },
  zod$({
    name: z.string().min(1, "Name is required"),
    email: z.string().email("Invalid email address"),
    message: z.string().min(10, "Message must be at least 10 characters"),
  }),
);

export default component$(() => {
  const action = useContactAction();

  return (
    <Form action={action}>
      <div>
        <input name="name" />
        {action.value?.failed && action.value.fieldErrors?.name && (
          <p class="text-sm text-red-600">{action.value.fieldErrors.name}</p>
        )}
      </div>

      <div>
        <input
          name="email"
          type="email"
        />
        {action.value?.failed && action.value.fieldErrors?.email && (
          <p class="text-sm text-red-600">{action.value.fieldErrors.email}</p>
        )}
      </div>

      <div>
        <textarea name="message" />
        {action.value?.failed && action.value.fieldErrors?.message && (
          <p class="text-sm text-red-600">{action.value.fieldErrors.message}</p>
        )}
      </div>

      {action.value?.failed && action.value.message && (
        <div class="rounded-lg bg-red-50 p-3 text-sm text-red-800">
          {action.value.message}
        </div>
      )}

      <button
        type="submit"
        disabled={action.isRunning}
      >
        {action.isRunning ? "Sending..." : "Send Message"}
      </button>
    </Form>
  );
});

useError() — Client-Side Error State

A Nuxt-inspired composable for accessing and managing error state in components:

tsx
// hooks/use-error.ts
import { $, useSignal } from "@builder.io/qwik";
import type { AppError } from "~/lib/errors";

export function useError() {
  const error = useSignal<AppError | null>(null);

  const showError = $((err: AppError) => {
    error.value = err;
  });

  const clearError = $(() => {
    error.value = null;
  });

  return { error, showError, clearError };
}

Usage:

tsx
import { component$ } from "@builder.io/qwik";
import { useError } from "~/hooks/use-error";
import { createError } from "~/lib/errors";

export default component$(() => {
  const { error, showError, clearError } = useError();

  const handleDelete = $(async () => {
    try {
      await trpc.posts.delete.mutate({ id: "123" });
    } catch (err) {
      showError(
        createError({
          statusCode: 500,
          message: "Failed to delete post",
          code: "DELETE_FAILED",
        }),
      );
    }
  });

  return (
    <div>
      {error.value && (
        <div class="rounded-lg border border-red-200 bg-red-50 p-4">
          <p class="text-red-800">{error.value.message}</p>
          <button
            onClick$={clearError}
            class="mt-2 text-sm underline"
          >
            Dismiss
          </button>
        </div>
      )}
      <button onClick$={handleDelete}>Delete Post</button>
    </div>
  );
});

Uncaught Error Handlers

Configure global handlers for errors that escape all boundaries. These are set up in the client entry point:

typescript
// client/entry.ts

// Catches errors in event handlers and async code not wrapped in boundaries
window.addEventListener("error", (event) => {
  reportErrorToService({
    type: "uncaught",
    message: event.message,
    filename: event.filename,
    line: event.lineno,
    column: event.colno,
    stack: event.error?.stack,
  });
});

// Catches unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
  reportErrorToService({
    type: "unhandled_rejection",
    message: String(event.reason),
    stack: event.reason?.stack,
  });
});

function reportErrorToService(error: Record<string, unknown>) {
  // Send to your error tracking service (Sentry, etc.)
  if (import.meta.env.DEV) {
    console.error("[Scratchy Error]", error);
  }
  navigator.sendBeacon("/api/errors", JSON.stringify(error));
}

Server-Side Error Handling

Fastify Error Handler

Set a custom error handler on the Fastify instance to normalize all server errors into a consistent JSON envelope:

typescript
// server.ts
import { hasZodFastifySchemaValidationErrors } from "fastify-type-provider-zod";
import { isAppError } from "~/lib/errors.js";

server.setErrorHandler((error, request, reply) => {
  // 1. Zod validation errors (from route schemas)
  if (hasZodFastifySchemaValidationErrors(error)) {
    request.log.warn(
      { validation: error.validation, url: request.url },
      "validation error",
    );
    return reply.status(400).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Request validation failed",
        statusCode: 400,
        details: error.validation,
      },
    });
  }

  // 2. AppError (from createError())
  if (isAppError(error)) {
    const level = error.statusCode >= 500 ? "error" : "warn";
    request.log[level](
      { err: error, statusCode: error.statusCode },
      error.message,
    );
    return reply.status(error.statusCode).send(error.toJSON());
  }

  // 3. Fastify errors (404, 413, etc.)
  if (error.statusCode && error.statusCode < 500) {
    request.log.warn({ err: error, url: request.url }, error.message);
    return reply.status(error.statusCode).send({
      error: {
        code: `E_HTTP_${error.statusCode}`,
        message: error.message,
        statusCode: error.statusCode,
      },
    });
  }

  // 4. Unexpected errors — log full details, return generic message
  request.log.error(
    { err: error, url: request.url, method: request.method },
    "unhandled server error",
  );

  const isDev = process.env.NODE_ENV !== "production";
  return reply.status(500).send({
    error: {
      code: "INTERNAL_SERVER_ERROR",
      message: isDev ? error.message : "An unexpected error occurred",
      statusCode: 500,
      ...(isDev && { stack: error.stack }),
    },
  });
});

Not-Found Handler

typescript
server.setNotFoundHandler(
  {
    preHandler: server.rateLimit({ max: 60, timeWindow: "1 hour" }),
  },
  (request, reply) => {
    request.log.warn(
      { url: request.url, method: request.method },
      "route not found",
    );
    return reply.status(404).send({
      error: {
        code: "NOT_FOUND",
        message: `Route ${request.method} ${request.url} not found`,
        statusCode: 404,
      },
    });
  },
);

Structured Error Response Envelope

All API errors follow a consistent JSON structure:

typescript
// types/error-response.ts
interface ErrorEnvelope {
  error: {
    code: string;
    message: string;
    statusCode: number;
    details?: unknown;
    data?: Record<string, unknown>;
    stack?: string; // Development only
  };
}

Example responses:

json
// 400 Validation Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "statusCode": 400,
    "details": [
      {
        "path": ["body", "email"],
        "message": "Invalid email address"
      }
    ]
  }
}

// 404 Not Found
{
  "error": {
    "code": "COURSE_NOT_FOUND",
    "message": "Course abc123 not found",
    "statusCode": 404
  }
}

// 500 Internal Server Error (production)
{
  "error": {
    "code": "INTERNAL_SERVER_ERROR",
    "message": "An unexpected error occurred",
    "statusCode": 500
  }
}

tRPC Error Handling

Throwing tRPC Errors

Use TRPCError with the appropriate code in all tRPC procedures:

typescript
import { TRPCError } from "@trpc/server";

export const courseQueries = {
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const [course] = await findCourseById.execute({ id: input.id });

      if (!course) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: `Course ${input.id} not found`,
        });
      }

      return course;
    }),
};

tRPC Error Code Reference

CodeHTTP StatusUse Case
BAD_REQUEST400Invalid input that passed Zod validation
UNAUTHORIZED401Missing or invalid authentication
FORBIDDEN403Authenticated but insufficient permissions
NOT_FOUND404Resource does not exist
METHOD_NOT_SUPPORTED405Operation not allowed on this resource
TIMEOUT408Operation took too long
CONFLICT409Resource already exists or state conflict
PAYLOAD_TOO_LARGE413Request body exceeds size limit
UNPROCESSABLE_CONTENT422Semantically invalid input
TOO_MANY_REQUESTS429Rate limit exceeded
CLIENT_CLOSED_REQUEST499Client disconnected before response
INTERNAL_SERVER_ERROR500Unexpected server failure

tRPC Error Formatting

Configure the onError and errorFormatter in the tRPC adapter to shape error responses and log errors:

typescript
// server.ts — tRPC plugin registration
await server.register(fastifyTRPCPlugin, {
  prefix: "/trpc",
  trpcOptions: {
    router: appRouter,
    createContext,
    onError({ path, error, type, input }) {
      if (error.code === "INTERNAL_SERVER_ERROR") {
        // Log full error details for unexpected failures
        server.log.error(
          { err: error, path, type, input },
          "tRPC internal error",
        );
      }
    },
  },
});

Wrapping External Service Errors

When calling external services from tRPC procedures, catch their errors and re-throw as TRPCError:

typescript
export const paymentMutations = {
  charge: protectedProcedure
    .input(z.object({ amount: z.number().positive() }))
    .mutation(async ({ input, ctx }) => {
      let result: ChargeResult;
      try {
        result = await paymentGateway.charge(ctx.user.id, input.amount);
      } catch (err) {
        ctx.request.log.error(
          { err, userId: ctx.user.id },
          "payment charge failed",
        );

        const code =
          err instanceof Error ? (err as { code?: string }).code : undefined;

        if (code === "INSUFFICIENT_FUNDS") {
          throw new TRPCError({
            code: "BAD_REQUEST",
            message: "Insufficient funds for this transaction",
            cause: err,
          });
        }

        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Payment processing failed",
          cause: err,
        });
      }

      return result;
    }),
};

Worker Thread Error Handling

Error Propagation from Piscina Workers

Piscina automatically propagates errors thrown in workers back to the main thread as rejected promises. Wrap worker calls to handle failures:

typescript
// routes/pages/index.ts
import type { FastifyPluginAsync } from "fastify";
import { createError } from "~/lib/errors.js";

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.get("/*", async (request, reply) => {
    try {
      const result = await fastify.runTask({
        type: "ssr",
        route: request.url,
        props: { user: request.user },
      });

      return reply
        .status(result.statusCode)
        .header("content-type", "text/html; charset=utf-8")
        .send(result.html);
    } catch (error) {
      // Worker crashed or timed out
      request.log.error(
        { err: error, route: request.url },
        "SSR worker failed",
      );

      // Return a static fallback page
      return reply
        .status(500)
        .header("content-type", "text/html; charset=utf-8")
        .send(renderFallbackHtml(request.url));
    }
  });
};

export default routes;

Worker-Side Error Handling

Inside workers, catch errors and return structured results instead of letting them crash the worker:

typescript
// renderer/worker.ts
import type { RenderResult, RenderTask } from "~/types/renderer.js";

export default async function handler(task: RenderTask): Promise<RenderResult> {
  try {
    switch (task.type) {
      case "ssr":
        return await renderSSR(task.route, task.props);
      case "ssg":
        return await renderSSG(task.route, task.props);
      default:
        return {
          html: renderErrorHtml(400, `Unknown task type: ${task.type}`),
          head: "",
          statusCode: 400,
        };
    }
  } catch (error) {
    // Log in the worker context
    console.error("[Worker] Render error:", error);

    // Return an error page instead of crashing the worker
    const message =
      process.env.NODE_ENV === "production"
        ? "An error occurred while rendering this page"
        : error instanceof Error
          ? error.message
          : String(error);

    return {
      html: renderErrorHtml(500, message),
      head: "",
      statusCode: 500,
    };
  }
}

function renderErrorHtml(statusCode: number, message: string): string {
  return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Error ${statusCode}</title></head>
<body>
  <h1>Error ${statusCode}</h1>
  <p>${escapeHtml(message)}</p>
</body>
</html>`;
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

Worker Timeout Handling

Configure task timeouts in Piscina to prevent runaway workers:

typescript
// plugins/app/worker-pool.ts
import fp from "fastify-plugin";
import os from "node:os";
import { resolve } from "node:path";

export default fp(async function workerPool(fastify) {
  await fastify.register(import("fastify-piscina"), {
    worker: resolve(import.meta.dirname, "..", "..", "renderer", "worker.ts"),
    minThreads: 2,
    maxThreads: Math.max(4, os.availableParallelism()),
    idleTimeout: 60_000,
    taskTimeout: 30_000, // Kill worker tasks after 30 seconds
    resourceLimits: {
      maxOldGenerationSizeMb: 512,
    },
  });

  // Monitor pool health
  const pool = fastify.piscina;
  setInterval(() => {
    fastify.log.info(
      {
        queueSize: pool.queueSize,
        utilization: pool.utilization,
        runTime: pool.runTime,
        waitTime: pool.waitTime,
      },
      "worker pool stats",
    );
  }, 60_000);
});

Database Error Handling

Drizzle / PostgreSQL Error Patterns

Wrap database operations with proper error handling and map PostgreSQL error codes to meaningful application errors:

typescript
// lib/db-errors.ts
import { createError } from "~/lib/errors.js";

// PostgreSQL error codes
// See: https://www.postgresql.org/docs/current/errcodes-appendix.html
const PG_UNIQUE_VIOLATION = "23505";
const PG_FOREIGN_KEY_VIOLATION = "23503";
const PG_NOT_NULL_VIOLATION = "23502";
const PG_CHECK_VIOLATION = "23514";

interface PostgresError {
  code: string;
  detail?: string;
  constraint?: string;
  table?: string;
  column?: string;
}

function isPostgresError(error: unknown): error is PostgresError {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    typeof (error as PostgresError).code === "string"
  );
}

export function handleDatabaseError(error: unknown): never {
  if (!isPostgresError(error)) {
    throw createError({
      statusCode: 500,
      message: "An unexpected database error occurred",
      code: "DATABASE_ERROR",
      cause: error,
    });
  }

  switch (error.code) {
    case PG_UNIQUE_VIOLATION:
      throw createError({
        statusCode: 409,
        message: `A record with this value already exists`,
        code: "DUPLICATE_ENTRY",
        data: {
          constraint: error.constraint,
          detail: error.detail,
        },
      });

    case PG_FOREIGN_KEY_VIOLATION:
      throw createError({
        statusCode: 422,
        message: "Referenced record does not exist",
        code: "FOREIGN_KEY_VIOLATION",
        data: {
          constraint: error.constraint,
          detail: error.detail,
        },
      });

    case PG_NOT_NULL_VIOLATION:
      throw createError({
        statusCode: 400,
        message: `Required field "${error.column}" is missing`,
        code: "NOT_NULL_VIOLATION",
        data: { column: error.column, table: error.table },
      });

    case PG_CHECK_VIOLATION:
      throw createError({
        statusCode: 422,
        message: "Value violates a check constraint",
        code: "CHECK_VIOLATION",
        data: { constraint: error.constraint },
      });

    default:
      throw createError({
        statusCode: 500,
        message: "A database error occurred",
        code: "DATABASE_ERROR",
        data: { pgCode: error.code },
        cause: error,
      });
  }
}

Usage in Mutations

typescript
// db/mutations/users.ts
import { eq } from "drizzle-orm";
import { ulid } from "ulid";
import { db } from "~/db/index.js";
import { user } from "~/db/schema/user.js";
import type { NewUser } from "~/db/schema/user.js";
import { handleDatabaseError } from "~/lib/db-errors.js";

export async function createUser(data: Omit<NewUser, "id">) {
  try {
    const [newUser] = await db
      .insert(user)
      .values({ id: ulid(), ...data })
      .returning();
    return newUser;
  } catch (error) {
    handleDatabaseError(error);
  }
}

Connection Pool Error Handling

typescript
// db/index.ts

// Handle pool-level errors — log but do not crash the process
pool.on("error", (err) => {
  console.error("Database pool error:", err.message);
});

// Verify connectivity at startup
export async function checkDatabaseConnection(): Promise<boolean> {
  try {
    await pool.query("SELECT 1");
    return true;
  } catch (error) {
    console.error("Database connection check failed:", error);
    return false;
  }
}

Transaction Error Handling

typescript
import { db } from "~/db/index.js";
import { handleDatabaseError } from "~/lib/db-errors.js";

export async function transferCredits(
  fromId: string,
  toId: string,
  amount: number,
) {
  try {
    return await db.transaction(async (tx) => {
      const [sender] = await tx
        .select()
        .from(user)
        .where(eq(user.id, fromId))
        .for("update");

      if (!sender) {
        throw createError({
          statusCode: 404,
          message: "Sender not found",
          code: "USER_NOT_FOUND",
        });
      }

      if (sender.credits < amount) {
        throw createError({
          statusCode: 422,
          message: "Insufficient credits",
          code: "INSUFFICIENT_CREDITS",
          data: { available: sender.credits, requested: amount },
        });
      }

      await tx
        .update(user)
        .set({ credits: sender.credits - amount })
        .where(eq(user.id, fromId));

      // Drizzle's sql`` template tag parameterizes interpolated values,
      // preventing SQL injection. The amount is also validated as a number
      // by the Zod schema at the tRPC layer before reaching this point.
      await tx
        .update(user)
        .set({ credits: sql`${user.credits} + ${amount}` })
        .where(eq(user.id, toId));

      return { success: true };
    });
  } catch (error) {
    if (isAppError(error)) throw error;
    handleDatabaseError(error);
  }
}

Error Logging

Structured Logging with Pino

Scratchy uses Fastify's built-in Pino logger for structured JSON logging. Follow these rules for error logging:

typescript
// Inside route handlers — use request.log
fastify.get("/users/:id", async (request, reply) => {
  const [err, user] = await fastify.to(findUser(request.params.id));

  if (err) {
    // Always pass the error object first, then the message string
    request.log.error(
      { err, userId: request.params.id },
      "failed to fetch user",
    );
    throw createError({ statusCode: 500, message: "Failed to fetch user" });
  }

  return user;
});

// Inside plugins — use fastify.log
export default fp(async function cachePlugin(fastify) {
  try {
    await connectToRedis();
    fastify.log.info("cache connected");
  } catch (error) {
    fastify.log.error({ err: error }, "cache connection failed");
  }
});

Logging Rules

  1. Use request.log inside route handlers — it automatically includes the request ID for correlation.
  2. Use fastify.log only in plugin-level or startup code.
  3. Always pass an object first: request.log.error({ err, key: value }, "message").
  4. Never use string interpolation in log messages — use structured fields.
  5. Name the error field err — Pino serializes Error objects under this key automatically.
  6. Log at the boundary — log where errors are caught, not where they are thrown. This prevents duplicate log entries.
typescript
// ❌ BAD — string interpolation, no structure
request.log.error(`Failed to fetch user ${userId}: ${error.message}`);

// ✅ GOOD — structured fields
request.log.error({ err: error, userId }, "failed to fetch user");

Log Levels for Errors

LevelWhen to Use
fatalProcess must exit — unrecoverable state
errorUnexpected failure — 5xx errors, unhandled exceptions
warnExpected failure — 4xx errors, validation failures, retries
infoNormal operations — request completed, task finished

Development vs Production

Development Error Overlay

In development mode, Scratchy provides a detailed error overlay that shows:

  • Full error message and stack trace
  • Source code context with the error location highlighted
  • Request details (URL, method, headers, body)
  • Component tree showing where the error occurred
typescript
// plugins/app/dev-errors.ts
import fp from "fastify-plugin";

export default fp(async function devErrors(fastify) {
  if (process.env.NODE_ENV !== "production") {
    fastify.addHook("onError", async (request, reply, error) => {
      // In development, attach extra debug info to the response
      reply.header(
        "x-error-code",
        (error as { code?: string }).code ?? "UNKNOWN",
      );
      reply.header("x-error-type", error.constructor.name);
    });
  }
});

Production Error Display

In production, never expose internal details:

typescript
// server.ts
const isDev = process.env.NODE_ENV !== "production";

server.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error }, "request error");

  return reply.status(error.statusCode ?? 500).send({
    error: {
      code: (error as { code?: string }).code ?? "INTERNAL_SERVER_ERROR",
      message: isDev ? error.message : "An unexpected error occurred",
      statusCode: error.statusCode ?? 500,
      // Only include stack traces in development
      ...(isDev && { stack: error.stack }),
    },
  });
});

Error Page Rendering by Environment

tsx
// components/error-display.tsx
import { component$ } from "@builder.io/qwik";

interface ErrorDisplayProps {
  statusCode: number;
  message: string;
  stack?: string;
}

export const ErrorDisplay = component$<ErrorDisplayProps>(
  ({ statusCode, message, stack }) => {
    const isDev = import.meta.env.DEV;

    return (
      <div class="mx-auto max-w-3xl px-4 py-16">
        <div class="text-center">
          <h1 class="text-6xl font-bold text-gray-900 dark:text-white">
            {statusCode}
          </h1>
          <p class="mt-4 text-lg text-gray-600 dark:text-gray-400">{message}</p>
        </div>

        {isDev && stack && (
          <pre class="mt-8 overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm text-gray-100">
            <code>{stack}</code>
          </pre>
        )}

        <div class="mt-8 text-center">
          <a
            href="/"
            class="bg-primary-600 hover:bg-primary-700 inline-block rounded-lg px-6 py-3 text-white"
          >
            Go Home
          </a>
        </div>
      </div>
    );
  },
);

Graceful Shutdown and Fatal Errors

Graceful Shutdown

Use close-with-grace to handle process signals and drain connections:

typescript
// server.ts
import closeWithGrace from "close-with-grace";

closeWithGrace({ delay: 10_000 }, async ({ signal, err }) => {
  if (err) {
    server.log.error(err, "server closing due to error");
  }
  server.log.info({ signal }, "shutting down gracefully");
  await server.close();
});

Critical: Never call closeWithGrace() inside a process.on('uncaughtException') handler. The close-with-grace library already handles uncaught exceptions internally.

Fatal Error Recovery

For errors so severe the process cannot continue, log and exit:

typescript
// Handled by close-with-grace automatically, but for truly fatal situations:
process.on("unhandledRejection", (reason) => {
  // close-with-grace intercepts this. If it doesn't, force exit:
  server.log.fatal({ err: reason }, "unhandled rejection — shutting down");
  process.exit(1);
});

Best Practices

  1. Use createError() for all thrown errors — ensures consistent structure and serialization across the stack.

  2. Use ErrorResponse for expected non-200 outcomes — when a non-success status is a normal return value, not an exception.

  3. Catch errors at the boundary, not at every level — let errors propagate up to the appropriate handler. Avoid try/catch blocks that silently swallow errors.

  4. Always include cause — when re-throwing or wrapping errors, pass the original error as cause for debugging:

    typescript
    throw createError({
      statusCode: 502,
      message: "Payment gateway unavailable",
      cause: originalError,
    });
  5. Map database errors to HTTP errors — use handleDatabaseError() to translate PostgreSQL error codes into user-friendly AppError instances.

  6. Log once at the boundary — do not log the same error at multiple layers. Log it where it is caught and handled.

  7. Use type guards — always check error types with isAppError(), isRouteErrorResponse(), and isPostgresError() instead of instanceof across module boundaries (which can fail with different module instances).

  8. Never expose stack traces in production — gate stack trace inclusion behind NODE_ENV !== "production".

  9. Provide actionable error messages — tell the user what happened and what they can do about it, not internal implementation details.

  10. Test error paths — write tests for error cases, not just happy paths. Verify that correct status codes, error codes, and messages are returned.


Anti-Patterns

❌ Don't silently swallow errors

typescript
// BAD — Error is lost
try {
  await riskyOperation();
} catch {
  // do nothing
}

// GOOD — Log or re-throw
try {
  await riskyOperation();
} catch (error) {
  request.log.error({ err: error }, "risky operation failed");
  throw createError({
    statusCode: 500,
    message: "Operation failed",
    cause: error,
  });
}

❌ Don't throw plain strings or objects

typescript
// BAD — No stack trace, no structure
throw "Something went wrong";
throw { message: "error" };

// GOOD — Structured error
throw createError({ statusCode: 500, message: "Something went wrong" });

❌ Don't expose internal details to clients

typescript
// BAD — Leaks table name and query
throw new TRPCError({
  code: "INTERNAL_SERVER_ERROR",
  message: `SELECT * FROM users WHERE id = '${id}' failed: connection refused`,
});

// GOOD — Generic message, details logged server-side
request.log.error({ err: dbError, userId: id }, "user query failed");
throw new TRPCError({
  code: "INTERNAL_SERVER_ERROR",
  message: "Failed to load user data",
});

❌ Don't use any in error handlers

typescript
// BAD
} catch (error: any) {
  console.log(error.message);
}

// GOOD — Use unknown and narrow
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  request.log.error({ err: error }, message);
}

❌ Don't mix async/await with done() callbacks in Fastify hooks

typescript
// BAD — Causes double response
fastify.addHook("onError", async (request, reply, error, done) => {
  done();
});

// GOOD
fastify.addHook("onError", async (request, reply, error) => {
  request.log.error({ err: error }, "hook error");
});

❌ Don't catch errors too early

typescript
// BAD — Catches at the wrong layer, loses context
async function getUser(id: string) {
  try {
    return await findUserById.execute({ id });
  } catch {
    return null; // Caller has no idea an error occurred
  }
}

// GOOD — Let it propagate to the handler
async function getUser(id: string) {
  return findUserById.execute({ id });
}

  • API Design — tRPC error codes and REST error envelopes
  • Security — Error information leakage prevention
  • Middleware — Error hooks and lifecycle
  • Data Layer — Database error patterns and transactions
  • Rendering — Worker error propagation
  • Streaming — Error handling during streaming SSR