Ploy
Ploy

Workers

Deploy serverless workers on Ploy — HTTP handlers with fast cold starts, plus cron triggers, timers, state, cache, and file storage.

Workers

Workers are the compute layer of Ploy. Every dynamic project — a plain worker, a Vite or TanStack Start app, or a Next.js app through its adapter — runs as a worker. Workers are lightweight, fast, and scale automatically to handle incoming requests.

If you're migrating an existing Cloudflare Worker, Ploy is designed to let you keep the same D1 and KV calling patterns. Match the binding names in ploy.yaml, run ploy types, and most worker code can move over unchanged.

In this section

This page covers the worker runtime: the fetch handler, project structure, environment variables, routing, and limits. The other pages in this section cover the bindings that are scoped to a worker:

Databases, queues, workflows, AI, auth, and sandboxes are bindings too, but each is a product of its own: see Databases, Queues, Workflows, AI, Authentication, and Sandboxes.

What are Workers?

Workers are JavaScript/TypeScript functions that run in response to HTTP requests. They execute in a V8 isolate environment, providing:

  • Fast cold starts - Workers start in milliseconds
  • Global distribution - Run close to your users
  • Auto-scaling - Handle any amount of traffic
  • Cost-effective - Scale to zero when not in use

Basic Worker Example

Here's the simplest worker that responds to all requests:

src/index.ts
export default {
	fetch() {
		return new Response("hi!");
	},
};

This worker exports a default object with a fetch method that returns a plain text response.

Project Structure

A basic worker project requires:

my-worker/
├── src/
│   └── index.ts       # Worker entry point
├── package.json       # Project configuration
└── tsconfig.json      # TypeScript configuration

package.json

package.json
{
	"name": "my-worker",
	"version": "0.0.0",
	"private": true,
	"type": "module",
	"main": "src/index.ts",
	"scripts": {
		"build": "ploy build"
	},
	"dependencies": {
		"@meetploy/types": "latest"
	},
	"devDependencies": {
		"@meetploy/cli": "latest",
		"typescript": "5.9.2"
	}
}

The "type": "module" field is required for ES modules support. The main field should point to your entry file.

Request and Response

Handling Requests

The fetch method receives a Request object with information about the incoming request:

Ploy infers the full fetch() entrypoint signature automatically, so the docs omit explicit Request, PloyEnv, and ExecutionContext annotations on worker handlers.

src/index.ts
export default {
	async fetch(request) {
		const url = new URL(request.url);
		const path = url.pathname;

		if (path === "/") {
			return new Response("Home page");
		}

		if (path === "/about") {
			return new Response("About page");
		}

		return new Response("Not Found", { status: 404 });
	},
};

Response Types

You can return different types of responses:

// Plain text
new Response("Hello World");

// JSON
new Response(JSON.stringify({ message: "Hello" }), {
	headers: { "Content-Type": "application/json" },
});

// HTML
new Response("<h1>Hello World</h1>", {
	headers: { "Content-Type": "text/html" },
});

// With status code
new Response("Not Found", { status: 404 });

// With headers
new Response("OK", {
	headers: {
		"Content-Type": "text/plain",
		"Cache-Control": "max-age=3600",
	},
});

Background Tasks with ctx.waitUntil()

Workers on Ploy support ctx.waitUntil() with the same execution model as Cloudflare Workers. Ploy uses workerd's native execution context, so you can return a response immediately while background work continues until the promise settles.

Use this for non-blocking work such as analytics, cache warming, or webhook delivery:

src/index.ts
export default {
	async fetch(request, _env, ctx) {
		const url = new URL(request.url);

		if (url.pathname === "/signup") {
			ctx.waitUntil(
				fetch("https://example.com/webhooks/signup", {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify({
						email: url.searchParams.get("email"),
					}),
				}),
			);

			return Response.json({ queued: true });
		}

		return new Response("ok");
	},
};

The client gets the response right away, but the promise passed to ctx.waitUntil() keeps running in the background.

Environment Variables

Workers can access environment variables for configuration:

src/index.ts
export default {
	async fetch(request, env) {
		const apiKey = process.env.API_KEY;
		// env.vars carries the same values, if you prefer the handler's
		// env argument over a global.
		const region = env.vars.API_REGION;

		await fetch(`https://${region}.api.example.com/data`, {
			headers: {
				Authorization: `Bearer ${apiKey}`,
			},
		});

		return new Response("OK");
	},
} satisfies Ploy;

Set environment variables in your Ploy project settings, or in a .env file for local development. They are injected at runtime with nothing to declare. See Environment Variables for the details.

Cloudflare Compatibility

Ploy workers run on workerd, and Ploy's storage bindings now follow the Cloudflare shapes for the most common migration targets:

  • db bindings generate Database types, which stay compatible with the Cloudflare D1Database API and support prepare, batch, exec, and withSession.
  • state bindings keep the same Ploy names but expose the Cloudflare KV API (get, put, delete, getWithMetadata, list) in addition to Ploy's set() and update().

That means code like this generally ports directly:

src/index.ts
export default {
	async fetch(_request, env) {
		await env.DB.exec(
			"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)",
		);

		await env.STATE.put("settings", JSON.stringify({ theme: "dark" }));
		const settings = await env.STATE.get("settings", "json");

		const user = await env.DB.prepare("SELECT 1 AS id, 'Ada' AS name").first<{
			id: number;
			name: string;
		}>();

		return Response.json({ user, settings });
	},
};

Routing

Create a simple router by parsing the URL pathname:

src/index.ts
export default {
	async fetch(request) {
		const url = new URL(request.url);

		// Health check endpoint
		if (url.pathname === "/health") {
			return new Response("ok");
		}

		// API endpoint
		if (url.pathname === "/api/users") {
			return new Response(
				JSON.stringify({
					users: [
						{ id: 1, name: "Alice" },
						{ id: 2, name: "Bob" },
					],
				}),
				{
					headers: { "Content-Type": "application/json" },
				},
			);
		}

		// Default response
		return new Response("Welcome!");
	},
};

Query Parameters

Access URL query parameters:

export default {
	async fetch(request) {
		const url = new URL(request.url);

		// Get query parameters
		const name = url.searchParams.get("name") || "Guest";
		const age = url.searchParams.get("age");

		return new Response(`Hello ${name}${age ? `, age ${age}` : ""}!`);
	},
};

Example request: /greet?name=Alice&age=25

HTTP Methods

Handle different HTTP methods:

export default {
	async fetch(request) {
		const url = new URL(request.url);

		if (url.pathname === "/api/data") {
			switch (request.method) {
				case "GET":
					return new Response(JSON.stringify({ data: "..." }), {
						headers: { "Content-Type": "application/json" },
					});

				case "POST":
					const body = await request.json();
					return new Response(JSON.stringify({ success: true, body }), {
						headers: { "Content-Type": "application/json" },
					});

				case "DELETE":
					return new Response(null, { status: 204 });

				default:
					return new Response("Method Not Allowed", { status: 405 });
			}
		}

		return new Response("Not Found", { status: 404 });
	},
};

Request Body

Parse different request body types:

export default {
	async fetch(request) {
		if (request.method !== "POST") {
			return new Response("Method Not Allowed", { status: 405 });
		}

		// Parse JSON
		if (request.headers.get("Content-Type")?.includes("application/json")) {
			const json = await request.json();
			return new Response(JSON.stringify({ received: json }), {
				headers: { "Content-Type": "application/json" },
			});
		}

		// Parse form data
		if (
			request.headers
				.get("Content-Type")
				?.includes("application/x-www-form-urlencoded")
		) {
			const formData = await request.formData();
			const name = formData.get("name");
			return new Response(`Hello ${name}!`);
		}

		// Plain text
		const text = await request.text();
		return new Response(`Received: ${text}`);
	},
};

Error Handling

Add error handling to your workers:

export default {
	async fetch(request) {
		try {
			const url = new URL(request.url);

			if (url.pathname === "/error") {
				throw new Error("Something went wrong!");
			}

			return new Response("Success");
		} catch (error) {
			console.error("Worker error:", error);

			return new Response(
				JSON.stringify({
					error: error instanceof Error ? error.message : "Unknown error",
				}),
				{
					status: 500,
					headers: { "Content-Type": "application/json" },
				},
			);
		}
	},
};

CPU Limits

Each invocation of a worker can spend a limited amount of CPU time running your JavaScript. Time spent waiting on I/O — fetch(), database queries, queue sends, timers — does not count. Only time your code is actually running.

InvocationFreeProEnterprise
HTTP request1 s5 s30 s
Queue message5 s30 s60 s
Timer fire5 s30 s60 s
Cron run10 s60 s120 s
Workflow execution (all of its steps)15 s120 s300 s
Startup (top-level code, per instance)5 s5 s5 s

The limit covers everything the invocation runs, including code after an await and work scheduled with ctx.waitUntil(). Startup covers your module's top-level code when an instance of your worker loads, and the evaluation of modules loaded with import().

When an invocation reaches its limit, Ploy stops it:

  • An HTTP request gets a 503 response with the body Worker exceeded its CPU time limit. (or ...while starting up.).
  • A queue message, timer fire, cron run, or workflow execution fails the same way an uncaught exception in your handler would. Queue messages are retried up to their attempt limit.
  • Your deployment's logs show an error line with how much CPU time was used.

Other requests to your worker keep running normally.

ploy dev does not enforce CPU limits. To check how close you are, look at the CPU time reported for each request in your deployment's logs.

To stay under the limit, move heavy work out of the request path: send it to a queue, break long jobs into workflow steps, and cache expensive results instead of recomputing them on every request.

TypeScript Support

Add TypeScript types for better development experience:

src/index.ts
interface Env {
	API_KEY: string;
	DATABASE_URL: string;
}

interface ApiResponse {
	success: boolean;
	data?: unknown;
	error?: string;
}

export default {
	async fetch(request, env) {
		const response: ApiResponse = {
			success: true,
			data: { message: "Hello from TypeScript!" },
		};

		return new Response(JSON.stringify(response), {
			headers: { "Content-Type": "application/json" },
		});
	},
};

Deployment

To deploy your worker to Ploy:

  1. Push your code to a GitHub repository
  2. Connect the repository in Ploy dashboard
  3. Configure project type as Dynamic
  4. Set environment variables if needed
  5. Push code to trigger deployment

Workers are automatically built and deployed when you push to your repository. Build logs are available in real-time.

Best Practices

  • Keep workers lightweight - Workers should respond quickly
  • Use async/await - Handle asynchronous operations properly
  • Set appropriate headers - Include Content-Type and caching headers
  • Handle errors gracefully - Always wrap code in try/catch blocks
  • Log important events - Use console.log() for debugging (visible in deployment logs)
  • Validate input - Check query parameters and request bodies
  • Return proper status codes - Use 200 for success, 404 for not found, 500 for errors

Next Steps

  • Ploy Start - Type-safe routing, validation, and OpenAPI for workers
  • Databases - Attach a SQLite database to your worker
  • AI - Call AI models from your workers
  • Configuration - Configure your Ploy project
  • Self-Host - Deploy Ploy on your own infrastructure

How is this guide?

Last updated on