diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..0d215b3
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,1241 @@
+# `@geajs/suspense` — Implementation Plan
+
+> **Branch**: `feat/suspense`
+> **Issue**: [#63 — Suspense Component](https://github.com/dashersw/gea/issues/63)
+> **Author**: Recep Şen
+> **Date**: 2026-04-13
+> **Status**: Planning complete — 31 design decisions resolved, ready for implementation
+
+---
+
+## 1. Package Decision
+
+### Name: `@geajs/suspense`
+
+All official packages in this monorepo use the `@geajs/` scope:
+
+| Package | Name |
+|---------|------|
+| Core runtime | `@geajs/core` |
+| SSR | `@geajs/ssr` |
+| UI library | `@geajs/ui` |
+| Mobile | `@geajs/mobile` |
+| Vite plugin | `@geajs/vite-plugin` |
+
+Therefore: **`@geajs/suspense`** (not `@geajs/core/suspense`, not a subpath export).
+
+### Separate Package vs. Core Integration
+
+**Decision: Separate package** — following the `@geajs/ssr` precedent.
+
+Rationale:
+- Tree-shaking: users who don't need Suspense pay zero cost
+- Versioning independence: Suspense can ship patches/features without bumping `@geajs/core`
+- API surface: keeps `@geajs/core` minimal per the framework's philosophy
+- Precedent: `@geajs/ssr` is also a "core extension" shipped as its own package
+
+`@geajs/core` will NOT export `Suspense` directly. Users import from `@geajs/suspense`.
+
+```ts
+// Usage
+import { Suspense } from '@geajs/suspense'
+```
+
+---
+
+## 2. Package Location
+
+```text
+packages/
+ gea-suspense/ ← new package
+ src/
+ index.ts ← public exports
+ suspense.ts ← core Suspense component
+ types.ts ← SuspenseProps, SuspenseState
+ abort.ts ← AbortController integration
+ triggers.ts ← viewport/idle/interaction triggers
+ tests/
+ suspense.test.ts ← unit tests
+ suspense-error.test.ts
+ suspense-timing.test.ts
+ suspense-abort.test.ts
+ suspense-triggers.test.ts
+ suspense-benchmarks.test.ts ← benchmarks
+ package.json
+ tsconfig.json
+ README.md
+```
+
+---
+
+## 3. Package Configuration (`package.json` skeleton)
+
+```json
+{
+ "name": "@geajs/suspense",
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Declarative async rendering boundaries for Gea framework",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./dist/index.d.mts",
+ "import": "./dist/index.mjs"
+ }
+ },
+ "peerDependencies": {
+ "@geajs/core": "*"
+ },
+ "devDependencies": {
+ "@geajs/core": "*",
+ "tsdown": "^0.21.2",
+ "tsx": "^4.21.0",
+ "typescript": "~5.8.0",
+ "jsdom": "^29.0.0",
+ "@types/node": "^25.5.0"
+ }
+}
+```
+
+**Zero runtime dependencies** — `@geajs/core` is a peer dep (not bundled), no third-party libs.
+
+Build tool: `tsdown` (same as `@geajs/core`, consistent toolchain).
+
+---
+
+## 4. Implementation Phases
+
+### Phase 1 — Core Suspense (Fallback + Resolve)
+
+**Goal**: `}>` works.
+
+Tasks:
+- [ ] Scaffold `packages/gea-suspense/` with `package.json`, `tsconfig.json`
+- [ ] `src/types.ts` — `SuspenseProps` interface (Phase 1 subset: `fallback`, `onResolve`, `progressive`)
+- [ ] `src/suspense.ts` — `Suspense` class extending `Component`
+ - Collect child components with pending `async created()` lifecycle
+ - `Promise.allSettled()` parallel resolution (anti-waterfall, enables partial render)
+ - After `allSettled()` completes: `delete child[GEA_CREATED_PROMISE]` on every child to prevent stale promise retention and enable correct retry semantics
+ - `queueMicrotask` batching — same-tick resolves grouped into single DOM update cycle
+ - CSS lifecycle classes: `suspense-entering` (loading), `suspense-entered` (resolved), `suspense-leaving` (unmounting)
+ - `GEA_SWAP_CHILD` reuse for fallback → content transition — **Suspense must call `dispose()` on the outgoing child instance BEFORE each swap** to prevent observer/listener leaks; `GEA_SWAP_CHILD` does not call `dispose()` internally
+ - Insert fallback on mount
+ - Swap to content on resolve
+- [ ] Add package to workspace `package.json`
+- [ ] `tests/suspense.test.ts` — unit tests:
+ - renders fallback initially
+ - replaces fallback with content on resolve
+ - parallel resolution (all children start simultaneously)
+ - works with no async children (immediate render)
+ - works with multiple async children
+- [ ] Export from `src/index.ts`
+- [ ] Add to monorepo root `tsconfig`
+
+**Deliverable**: Basic fallback/resolve works.
+
+---
+
+### Phase 2 — Error Handling + Retry
+
+**Goal**: `error={(err, retry) => }` works.
+
+Tasks:
+- [ ] `src/types.ts` — add `error`, `onError` to `SuspenseProps`
+- [ ] `src/suspense.ts` — error state management
+ - `Promise.allSettled()` for error handling — each child's `result.status` checked independently; no short-circuiting on first failure
+ - render `error(err, retry)` JSX on failure for each rejected child
+ - `retry()` protocol: for each failed child → `dispose()` old instance → re-instantiate → call `created()` → capture new `GEA_CREATED_PROMISE` → run new `allSettled()` cycle; already-resolved children's DOM is preserved
+ - `GEA_SWAP_CHILD` to switch between fallback/error/content states
+ - `onError(err)` callback invocation for each failed child; `onResolve(results)` IS called after all settle (Q41)
+ - **Auto-retry on prop change**: if a child is in error state when a reactive prop changes, error state resets and `retry()` runs automatically
+- [ ] Partial failure handling (some children resolve, some fail)
+- [ ] `tests/suspense-error.test.ts`:
+ - shows error UI when child throws
+ - retry re-runs async created
+ - retry succeeds and shows content
+ - `onError` callback called with correct error
+ - partial failure: resolved children render, failed children show error state independently (Promise.allSettled semantics)
+- [ ] Progressive mode (`progressive={true}`): render each child individually as it resolves; each failed child's error rendered at its position using `error(err, retry, index)`
+
+**Deliverable**: Error boundary with retry built into Suspense.
+
+---
+
+### Phase 3 — Timing + Race Condition Prevention
+
+**Goal**: Fast responses don't flash spinner; slow responses display spinner for at least `minimumFallback` ms.
+
+Tasks:
+- [ ] `src/types.ts` — add `timeout`, `minimumFallback`, `onFallback` to `SuspenseProps`
+- [ ] `src/suspense.ts`:
+ - `timeout` — delay timer before showing fallback (`setTimeout` → show fallback)
+ - `minimumFallback` — track `fallbackShownAt` timestamp; if resolve arrives too early, wait remaining ms
+ - `onFallback()` callback when fallback becomes visible
+ - Generation counter (monotonic integer) — stale async responses are discarded silently
+- [ ] `tests/suspense-timing.test.ts`:
+ - `timeout=200`: fast resolve (50ms) → fallback NEVER shown
+ - `timeout=200`: slow resolve (300ms) → fallback shown
+ - `minimumFallback=300`: resolve at 50ms but fallback shown → wait until 300ms
+ - timing interaction: `timeout=500, minimumFallback=300`, resolve at 750ms → fallback shown at 500ms, content shown at 800ms (250ms < 300ms minimum → wait remaining 50ms); if resolve at 400ms → fallback never shown, `minimumFallback` irrelevant
+ - generation counter: rapid re-mounts don't show stale content
+ - `onFallback` fires exactly once
+
+**Deliverable**: Configurable flicker prevention.
+
+---
+
+### Phase 4 — Stale-While-Refresh + AbortController
+
+**Goal**: Re-fetches show stale content with CSS class instead of flashing to skeleton.
+
+Tasks:
+- [ ] `src/abort.ts` — `AbortController` lifecycle integration
+ - Create controller on mount
+ - Abort on unmount
+ - Pass signal to `async created()` (TBD: parameter vs `this.abortSignal` — see Q4)
+- [ ] `src/types.ts` — add `staleWhileRefresh`, `AbortSignal` propagation
+- [ ] `src/suspense.ts`:
+ - `staleWhileRefresh=true`: on re-fetch, add `suspense-refreshing` CSS class to content container instead of swapping to fallback
+ - Optional `refreshing` render prop: `refreshing={(children) =>
{children}
}` — wraps stale content if provided
+ - Remove CSS class on new content arrival
+ - Abort previous operations when new fetch starts
+ - Memory leak prevention: abort on component unmount
+- [ ] `tests/suspense-abort.test.ts`:
+ - abort signal is aborted on unmount
+ - `staleWhileRefresh`: old content stays visible during refresh
+ - `staleWhileRefresh`: CSS class added/removed correctly
+ - rapid re-mounts don't leak AbortControllers
+
+**Deliverable**: No more skeleton flash on data refresh.
+
+---
+
+### Phase 5 — Trigger-Based Loading
+
+**Goal**: `` loads only when scrolled into view.
+
+Tasks:
+- [ ] `src/triggers.ts`:
+ - `"immediate"` — default, loads on mount
+ - `"idle"` — `requestIdleCallback` (with `setTimeout` fallback for Safari)
+ - `"viewport"` — `IntersectionObserver`
+ - `"interaction"` — `addEventListener("click")` / `"keydown"` attached to `marker.nextElementSibling`; re-attached after each `GEA_SWAP_CHILD` call; removed once trigger fires (one-shot)
+ - `"hover"` — `addEventListener("mouseenter")`
+ - `"timer(ms)"` — `setTimeout(ms)`
+- [ ] `src/types.ts` — add `trigger`, `prefetch` to `SuspenseProps`
+- [ ] `src/suspense.ts`:
+ - Wire trigger logic: only start child loading after trigger fires
+ - `prefetch="idle"` — pre-load during idle, display on trigger; if `async created()` fails during prefetch, trigger arrival shows error UI immediately (promise is already rejected) — `retry()` available to re-run
+ - Clean up observers/listeners on unmount
+ - **Trigger vs refresh contract**: triggers are one-shot for initial load only; subsequent re-fetches are driven by reactive prop changes propagating via `GEA_ON_PROP_CHANGE` to the Suspense boundary, which re-runs `async created()` on children — `staleWhileRefresh` applies to these reactive re-fetches, not to trigger re-fires
+- [ ] Router integration (TBD: auto-wrap or opt-in — see Q6)
+- [ ] `tests/suspense-triggers.test.ts`:
+ - `"immediate"` starts on mount
+ - `"idle"` defers until requestIdleCallback
+ - `"viewport"` uses IntersectionObserver (mock in tests)
+ - `"timer(500)"` delays 500ms
+ - observers cleaned up on unmount
+
+**Deliverable**: Deferred loading with all Angular `@defer`-inspired triggers.
+
+---
+
+### Phase 6 — SSR Streaming Integration
+
+**Goal**: Suspense works with `@geajs/ssr` streaming deferreds — same boundary on server and client.
+
+Tasks:
+- [ ] `src/types.ts` — add `ssrStreamId` to `SuspenseProps`
+- [ ] `src/suspense.ts` (client side):
+ - On hydration: find element by `ssrStreamId`
+ - If SSR stream already resolved → skip loading, go straight to "resolved" state
+ - If still showing SSR fallback → take over async operation client-side
+- [ ] `@geajs/ssr` coordination (may need minor changes to SSR deferred chunk format)
+- [ ] Tests (requires jsdom + SSR test helper):
+ - server renders fallback with correct ID
+ - client hydrates and continues where SSR left off
+ - edge case: SSR resolved before hydration
+ - edge case: SSR failed — client shows error boundary
+
+#### SSR Architecture (required design — currently underspecified)
+
+**Problem**: `@geajs/ssr`'s `renderToString` is synchronous — it calls `new ComponentClass(props)` and immediately calls `template()`. For `async created()` components, data is not available when `template()` runs, rendering empty/default content.
+
+**Required changes to `@geajs/ssr`**:
+
+1. **New `renderToStringAsync`** function (non-breaking addition):
+ ```ts
+ export async function renderToStringAsync(
+ ComponentClass: ComponentLike,
+ props?: object,
+ options?: SSROptions
+ ): Promise<{ html: string; deferreds: DeferredChunk[] }>
+ ```
+ This function awaits all `GEA_CREATED_PROMISE` instances before calling `template()`.
+
+2. **Suspense boundary registration**: When a `Suspense` component mounts server-side, it registers a `DeferredChunk` in the SSR context with its `ssrStreamId` as the placeholder ID.
+
+3. **Fallback HTML generation**: Server-side Suspense renders the `fallback` prop as initial HTML with a wrapper element carrying `id={ssrStreamId}`.
+
+4. **Stream coordination**: The existing `createSSRStream` / `handleRequest` pipeline in `stream.ts` picks up `deferreds` from the Suspense boundaries and streams `