Request in.Document out.

The Worker receives the request, builds flags for Elm, runs the generated app, performs any described effects, then returns HTML, JSON, a redirect, or an error response.

Runtime exports
createWorkerApp
renderApp
cloudflareEffects
Loader.requireUser
Action.requireUser
Loader.softExecute
Loader.transaction
Loader.custom
withJobs
createSseStream
createNamedSseStream
withTasks
runMigrations
sessionMiddleware
01

Worker handles framework assets first.

The request handler serves /styles.css, /__elm-ssr/islands.js, /__elm-ssr/islands-bundle.js, /health, /api/health, /api/routes, and /api/render before falling through to page rendering.

02

Read methods use the Loader.

GET and HEAD render through the route's page : Request -> Loader (Document msg). Unsupported methods return 405; POST-style work belongs in action.

03

Elm describes effects.

A Loader or Action can pause on effects like fetch, cache, SQL, env, cookie, queue, or session. The Worker runs the effect and resumes Elm with decoded data.

04

The result chooses the HTTP response.

The runtime returns a redirect, JSON, or a serialized Document. Action cookies are appended as Set-Cookie headers.

Worker setup

The generated Elm module plugs into createWorkerApp.

The app supplies the compiled Elm module, stylesheet text, route catalog, island metadata, an optional island bundle, a flag factory, and optional effects.

export const worker = createWorkerApp({
  elmModule: Main,
  stylesheet,
  routes,
  islands,
  islandsBundle,
  createFlags,
  effects: cloudflareEffects(env)
});
Optional middleware

Sessions and CSRF are opt-in.

The runtime installs error, request id, timing, logging, and HEAD middleware. Sessions add session middleware and wrap effects; CSRF requires sessions.

createWorkerApp({
  ...app,
  sessions: sessionOptions,
  csrf: true
})
Example

Cache first

Cache helpers are backend-neutral; the selected TS-side effect runner decides whether the backing store is local, KV, Redis, or another adapter.

Loader.cacheGet
    { key = "home:data"
    , decoder = dataDecoder
    }
Example

SQL query

query, queryOne, and execute use SQL plus JSON params. Actions can reuse them through Action.fromLoader.

Loader.queryOne
    { sql = "select * from posts where slug = ?"
    , params = [ Encode.string slug ]
    , decoder = postDecoder
    }
Example

Background task

enqueue describes fire-and-forget work. The Worker task adapter decides how to run it, including waitUntil or queues.

Loader.enqueue
    { task = "send-welcome"
    , payload = Encode.string email
    }
Example

Cookie response

Actions can attach cookies to redirects, JSON responses, failures, or documents.

Action.redirect "/dashboard"
    |> Action.setCookie
        (Action.sessionCookie "session" sid)
Example

Constraint-safe write

softExecute and softQueryOne return Ok / Err ConstraintError instead of failing the Loader — handle UNIQUE violations as a normal business case.

Loader.softExecute
    { sql = "insert into subscribers(email) values (?)"
    , params = [ Encode.string email ]
    }
-- Ok { rowsAffected } | Err (ConstraintError msg)
Example

Atomic transaction

Loader.transaction wraps multi-step writes. If any step fails, everything rolls back. Requires sqlTransaction on the adapter.

Loader.transaction
    (Loader.execute
        { sql = "update orders set status = 'placed'"
             ++ " where id = ?"
        , params = [ Encode.string orderId ]
        }
    )
Database connection

Elm writes SQL descriptions. TypeScript owns the connection.

Use Loader.query, Loader.queryOne, and Loader.execute in routes. Use cloudflareEffects for D1, or postgresSql with your Postgres client for Postgres.

Postgres runner
import { inMemoryEffects } from "elm-ssr/effects";
import { postgresSql } from "elm-ssr/backends";

const baseEffects = inMemoryEffects({
  sql: postgresSql({
    run: (sql, params) =>
      pool.query(sql, params)
        .then((r) => ({ rows: r.rows, rowCount: r.rowCount ?? 0 }))
  })
});
Elm route
Loader.queryOne
    { sql = "select * from users where id = ?"
    , params = [ Encode.string userId ]
    , decoder = userDecoder
    }
Example

Custom effect

Loader.custom is the escape hatch for one typed Worker operation. Use it for parallel queries, AI calls, search, or a backend API that should not leak into route code.

Loader.custom
    { kind = "parallelDashboard"
    , payload = Encode.object []
    , decoder = dashboardDecoder
    }
Example

Background job

Loader.startJob returns a job id immediately. Loader.jobStatus decodes queued, running, done, failed, or missing states from the TS-side job store.

Loader.startJob
    { kind = "generateReport"
    , payload = Encode.object [ ( "month", Encode.string month ) ]
    }

Loader.jobStatus { jobId = id, decoder = reportDecoder }
Worker wrappers

Compose effects instead of changing Elm.

The Worker can wrap the base effect runner with tasks, queue producers, jobs, caches, sessions, or custom branches. The route module keeps the same Loader API.

const effects = withJobs(
  withQueueProducer(baseEffects, { queueBinding: "JOBS" }),
  { store: cacheJobStore(cache), handlers: { generateReport } }
);
Short task

Use waitUntil when the work can be retried manually.

withTasks handles Loader.enqueue by scheduling named handlers after the response. This is good for audits, cache warmups, and non-critical notifications.

const effects = withTasks(baseEffects, {
  auditEntry: async (payload, ctx) => {
    await writeAuditLog(payload);
  }
});
Durable task

Use queues when the work must survive the request.

withQueueProducer sends Loader.enqueue payloads to a Cloudflare Queue. createQueueConsumer maps each queued task to a handler.

export default {
  fetch: worker.fetch,
  queue: createQueueConsumer({ auditEntry, warmCache })
};
Sessions

Session effects require middleware.

Mount sessions on createWorkerApp. Then Elm can use Loader.session, Loader.csrfToken, Loader.setSession, and Loader.clearSession.

Worker setup
import { cacheStore } from "elm-ssr/sessions";

createWorkerApp({
  ...app,
  sessions: {
    secret: env.SESSION_SECRET,
    store: cacheStore(redisCache(redis))
  },
  csrf: true
});
Elm action
Action.fromLoader (Loader.setSession (encodeUser user))
    |> Action.andThen (\_ -> Action.redirect "/dashboard")

Action.fromLoader Loader.clearSession
    |> Action.andThen (\_ -> Action.redirect "/")
Route guards

Protect pages and actions with one call.

Loader.requireUser decodes the session cookie into your User type and redirects unauthenticated visitors to /login. Action.requireUser does the same for POST-style actions. A malformed session cookie produces **502**, not a redirect — test this explicitly.

Protected page
page request =
    Loader.requireUser userDecoder request
        |> Loader.andThen (\user ->
            Loader.map2
                (viewProfile user)
                (Loader.csrfToken request)
        )
Protected action
action request =
    Action.requireUser userDecoder request
        |> Action.andThen (\user ->
            Action.fromLoader (savePost user.id title)
                |> Action.andThen
                    (\_ -> Action.redirect "/posts")
        )
Inline redirect

Loader.redirect issues a 302 directly from a Loader without going through an Action. Useful when a page needs a conditional redirect before rendering.

page request =
    case Route.query "ref" request of
        Nothing ->
            Loader.redirect "/home"
        Just ref ->
            loadWithRef ref
Effect adapters

Elm code stays the same. The runner changes.

The upstream README frames effects as backend-neutral. Elm describes cacheGet, query, env, fetchJson, enqueue, custom, startJob, or jobStatus; TypeScript adapters decide whether that means KV/D1, Redis/Postgres, SQLite, memory, queues, waitUntil, or a custom service.

Cloudflare deploy
import { cloudflareEffects } from "elm-ssr/effects";
import { withTasks } from "elm-ssr/tasks";

const effects = withTasks(
  cloudflareEffects({ cacheBinding: "CACHE", dbBinding: "DB" }),
  { sendEmail, warmCache }
);
Local tests
const effects = withTasks(
  withCache(
    inMemoryEffects({ sql: postgresSql(pg), env }),
    redisCache(redis)
  ),
  { sendEmail, warmCache }
);
Migrations

Schema changes can ship with the app.

The README documents SQL-file migrations with a tracking table. Files apply in alphabetical order, and each migration plus its tracking insert runs transactionally when the adapter supports transactions.

Up

elm-ssr migrate up --dir ./migrations --db ./app.db

Status

elm-ssr migrate status --dir ./migrations --db ./app.db

Down

elm-ssr migrate down --count 1 --dir ./migrations --db ./app.db

Backends

The CLI accepts Postgres URLs, SQLite URLs, plain SQLite paths, or DATABASE_URL.

Programmatic API
import { runMigrations } from "elm-ssr/migrations";

const result = await runMigrations(adapter, {
  dir: "./migrations"
});

The adapter shape is small enough to wire to Bun SQLite, Postgres clients, or Cloudflare D1.

SSE

Streams are Worker endpoints plus island subscriptions.

createSseStream returns a text/event-stream response. ElmSsr.Island.Sse opens an EventSource per URL and feeds messages into an island subscription.

return createSseStream(request, async (send, signal) => {
  while (!signal.aborted) {
    send(JSON.stringify({ time: Date.now() }));
    await new Promise((r) => setTimeout(r, 1000));
  }
});
Island side
init flags =
    ( model, Sse.open "/events" )

subscriptions _ =
    Sse.events GotEvent

Sse.match "/events" decoder event

Use SSE for live updates, not initial page data. Initial data belongs in the Loader so the HTML is useful before any client code runs.

Islands

Server markup first. Client app only at the marker.

Island.embed renders an elm-ssr-island element with data-elmssr-island, encoded props, an optional stable id, and fallback children. The browser runtime finds that marker and starts the island's own Elm main.

Fallback

HTML rendered on the server before the island bundle starts.

Flags

Props are encoded as JSON by the server-side embed config.

Stable id

Optional id lets the runtime preserve an island across client navigation.

Normal Elm

The island internals use ordinary Elm browser code.

Embed shape
Island.embed "RuntimePulse"
  { encodeFlags = encodeFlags
  , fallback = fallback
  , id = Just "runtime-pulse"
  }
  flags

The island name must match the generated browser bundle key for the island module.

Guarantees

What you can rely on.

  • SSR render is request-scoped, including env and bindings on the effect runner.
  • Island state is client-scoped and isolated per mounted root.
  • Pages without island markers do not load the island runtime.
  • Worker concerns stay outside author-facing Elm route modules.
Tradeoffs

The boundary is deliberate.

  • Pages use ElmSsr.Html / ElmSsr.Svg, not stock elm/html, because Workers have no DOM.
  • Islands use stock Elm browser modules because they run in the browser.
  • Islands ship as one combined bundle per app; Elm does not provide lazy-loading primitives.
  • Durable background jobs need a queue adapter; waitUntil is for work after the response, not durable scheduling.