---
area: "Learn"
parent: "Advanced rendering chapter"
url: "https://unpoly.com/learn/render-lifecycle"
---
<nav aria-label="Unpoly docs">[All docs](https://unpoly.com/index.md) · [Advanced rendering chapter](https://unpoly.com/learn/advanced-rendering.md)</nav>

# Render lifecycle

Every render pass moves through the same stages: a request is sent, a response is loaded, fragments are swapped, then animations and cache revalidation settle. Your code can hook into each stage to run code after rendering, change render options on the fly, skip a response, or handle errors.

A render pass is one call to [`up.render()`](https://unpoly.com/api/up.render.md). It updates one or more fragments.

The techniques below apply to all functions and attributes that render, most notably [`up.render()`](https://unpoly.com/api/up.render.md), [`[up-follow]`](https://unpoly.com/api/up-follow.md) and [`[up-submit]`](https://unpoly.com/api/up-submit.md). For brevity we only use [`up.render()`](https://unpoly.com/api/up.render.md) in examples.

<nav aria-label="Contents">

#### Contents

- [Lifecycle diagram](#lifecycle-diagram)
- [Running code after rendering](#running-code-after-rendering)
- [Changing options before rendering](#changing-options-before-rendering)
- [Preventing a render pass](#preventing-a-render-pass)
- [Handling errors](#handling-errors)

</nav>

## Lifecycle diagram {#lifecycle-diagram}

The diagram below visualizes the sequence of render steps, edge cases and error states:

<figure>
<img src="https://unpoly.com/images/api/render-lifecycle.svg" alt="Flowchart of the render lifecycle">
<figcaption>

The diagram has three columns: the regular flow in the middle, edge cases on the left and error states on the right. A render pass moves through these stages:

1. A guard event is emitted, like [`up:link:follow`](https://unpoly.com/api/up:link:follow.md) or [`up:form:submit`](https://unpoly.com/api/up:form:submit.md). Preventing it rejects [`up.render()`](https://unpoly.com/api/up.render.md) with an `up.Aborted` error.
2. Default render options are applied from [`up.fragment.config.renderOptions`](https://unpoly.com/api/up.fragment.config.md#config.renderOptions) and, when navigating, from [`up.fragment.config.navigateOptions`](https://unpoly.com/api/up.fragment.config.md#config.navigateOptions).
3. With `{ cache: true }`, Unpoly looks for a cached response.
4. Conflicting requests are aborted.
5. Status effects are applied: previews, placeholders, disabling and [`.up-active`](https://unpoly.com/api/up-active.md). A cached response skips the status effects and the request, and continues with [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md).
6. The HTTP request is sent. When the response is received, the status effects are reverted. On a network issue or timeout, the status effects are reverted and [`up.render()`](https://unpoly.com/api/up.render.md) rejects with `up.Offline`, emitting [`up:fragment:offline`](https://unpoly.com/api/up:fragment:offline.md) and calling `onOffline()`. When another request targets the same fragment, the status effects are reverted and [`up.render()`](https://unpoly.com/api/up.render.md) rejects with an `up.Aborted` error.
7. [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) is emitted and `onLoaded()` is called. Preventing the event rejects [`up.render()`](https://unpoly.com/api/up.render.md) with an `up.Aborted` error. For an error response, the render pass continues with the fail-prefixed options, like `failTarget` and `onFailRendered`. An empty response skips [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) and updates no elements. It still goes through close conditions, focus and scrolling.
8. Required targets are matched. When the page or the response has no match, fallback targets like [`:main`](https://unpoly.com/api/main.md) are tried. When no fallback matches, [`up.render()`](https://unpoly.com/api/up.render.md) rejects with `up.CannotMatch`. When updating an existing layer, optional targets are matched too ([`[up-hungry]`](https://unpoly.com/api/up-hungry.md), [`:maybe`](https://unpoly.com/api/maybe.md)).
9. Asset versions are compared. When assets have changed, [`up:assets:changed`](https://unpoly.com/api/up:assets:changed.md) is emitted.
10. When opening a new layer, the layer is created ([`up:layer:open`](https://unpoly.com/api/up:layer:open.md)).
11. Layer close conditions are checked. When the layer closes, [`up.render()`](https://unpoly.com/api/up.render.md) rejects with an `up.Aborted` error.
12. History is updated ([`up:location:changed`](https://unpoly.com/api/up:location:changed.md), [`up:layer:location:changed`](https://unpoly.com/api/up:layer:location:changed.md)).
13. DOM elements are updated. For [`[up-keep]`](https://unpoly.com/api/up-keep.md) elements, [`up:fragment:keep`](https://unpoly.com/api/up:fragment:keep.md) is emitted. Preventing that event updates the element after all. When every targeted element is kept, no compilers run and `onRendered()` is not called. Destructors run on old elements, unless they play a transition.
14. Compilers run on new elements ([`up:fragment:inserted`](https://unpoly.com/api/up:fragment:inserted.md)). When a compiler or destructor throws, an `error` event is emitted on `window` and the render pass continues.
15. Focus and scroll position are updated. A new layer then emits [`up:layer:opened`](https://unpoly.com/api/up:layer:opened.md). Transitions and animations start. Old elements without a transition emit [`up:fragment:destroyed`](https://unpoly.com/api/up:fragment:destroyed.md).
16. After a successful response, [`up.render()`](https://unpoly.com/api/up.render.md) fulfills and `onRendered()` is called with an [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md). After an error response, [`up.render()`](https://unpoly.com/api/up.render.md) rejects with an [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md) and `onFailRendered()` is called.
17. The browser renders the changes while the transition (or `failTransition`) plays and async compilers settle. When the transition ends, destructors run on the old element and it emits [`up:fragment:destroyed`](https://unpoly.com/api/up:fragment:destroyed.md).
18. `up.render().finished` fulfills and `onFinished()` is called with the latest [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md). After an error response, `up.render().finished` rejects with an [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md).

When a render pass [updates multiple fragments](https://unpoly.com/learn/targeting-fragments.md#multiple), stages 13 to 15 each complete for all fragments before the next stage begins. Within one layer, fragments are updated in the order of the target selector, followed by that layer's [hungry elements](https://unpoly.com/learn/hungry-elements.md). Compilers run in the same order, and see the new versions of all fragments in that layer. Hungry elements on [other layers](https://unpoly.com/learn/hungry-elements.md#layers) are rendered as a separate group: after the current layer, or before it closes when the render pass closes an overlay.

`onError()` is called for every error that rejects [`up.render()`](https://unpoly.com/api/up.render.md), like `up.Offline`, `up.CannotMatch` or an aborted render pass, but not for an error response.

When the rendered content was expired cache content, Unpoly sends a revalidation request (a conditional GET) after the transition and async compilers have settled. Its response goes through [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) (with `{ revalidating: true }`), matching targets, updating the DOM and running compilers. When the server sends new content, `onRendered()` is called a second time. `up.render().finished` waits for the revalidation. When it fails from a network issue or timeout, `up.render().finished` rejects with `up.Offline`, emitting [`up:fragment:offline`](https://unpoly.com/api/up:fragment:offline.md) and calling `onOffline()`.

</figcaption>
</figure>

Each stage has an option or event to hook into. Many of them are explained on this page. The others have a page of their own:

| Intent | JavaScript | HTML |
| --- | --- | --- |
| Intervene on interaction | Guard events like [`up:link:follow`](https://unpoly.com/api/up:link:follow.md) or [`up:form:submit`](https://unpoly.com/api/up:form:submit.md) | — |
| Control concurrency | [`{ abort }`](https://unpoly.com/learn/aborting-requests.md) | [`[up-abort]`](https://unpoly.com/learn/aborting-requests.md) |
| Control concurrency | [`{ abortable }`](https://unpoly.com/learn/aborting-requests.md) | [`[up-abortable]`](https://unpoly.com/learn/aborting-requests.md) |
| Change page during request | [`{ disable }`](https://unpoly.com/learn/disabling-forms.md) | [`[up-disable]`](https://unpoly.com/learn/disabling-forms.md) |
| Change page during request | [`{ placeholder }`](https://unpoly.com/learn/placeholders.md) | [`[up-placeholder]`](https://unpoly.com/learn/placeholders.md) |
| Change page during request | [`{ preview }`](https://unpoly.com/learn/previews.md) | [`[up-preview]`](https://unpoly.com/learn/previews.md) |
| Intervene before rendering | [`{ onLoaded }`](https://unpoly.com/api/up.render.md#options.onLoaded) | [`[up-on-loaded]`](https://unpoly.com/api/up-follow.md#up-on-loaded) |
| Intervene before rendering | [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) | — |
| Modify new elements | [`{ onRendered }`](https://unpoly.com/api/up.render.md#options.onRendered) | [`[up-on-rendered]`](https://unpoly.com/api/up-follow.md#up-on-rendered) |
| Modify new elements | [`up.compiler()`](https://unpoly.com/api/up.compiler.md) | — |
| Modify new elements | [`up:fragment:inserted`](https://unpoly.com/api/up:fragment:inserted.md) | — |
| Keep elements within a fragment | [`{ keep }`](https://unpoly.com/api/up.render.md#options.keep) | [`[up-keep]`](https://unpoly.com/learn/keeping-elements.md) |
| Control scrolling | [`{ scroll }`](https://unpoly.com/learn/scrolling.md) | [`[up-scroll]`](https://unpoly.com/learn/scrolling.md) |
| Control focus | [`{ focus }`](https://unpoly.com/learn/focus.md) | [`[up-focus]`](https://unpoly.com/learn/focus.md) |

For a full list of available options see [`up.render()` parameters](https://unpoly.com/api/up.render.md#parameters).

## Running code after rendering {#running-code-after-rendering}

Rendering functions return a promise that fulfills when fragments were inserted and [compiled](https://unpoly.com/api/up.hello.md). To run code after fragments were updated, `await` that promise:

```js
await up.render({ url: '/path', target: '.target' })
console.log("Updated fragment is", document.querySelector('.target'))
```

The promise rejects when there is a [network issue](https://unpoly.com/learn/network-issues.md), or if the server [responds with an HTTP error code](https://unpoly.com/learn/error-responses.md). See [Handling errors](#handling-errors).

### Inspecting the render result {#inspecting-the-render-result}

The promise resolves to an [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md) object describing the effects of the render pass:

```js
let result = await up.render({ url: '/path', target: '.target', failTarget: '.errors' })
console.log("Updated layer: ", result.layer)
console.log("Updated fragments: ", result.fragments)
console.log("Effective options used: ", result.renderOptions)
```

When the pass updated a single fragment, `result.fragment` returns it directly.

### Running code after each render pass {#running-code-after-each-render-pass}

To run code after every render pass, pass an [`{ onRendered }`](https://unpoly.com/api/up.render.md#options.onRendered) callback. This callback may be called zero, one or two times:

- When the server rendered an [empty response](https://unpoly.com/learn/conditional-requests.md#rendering-nothing), no fragments are updated. `{ onRendered }` is not called.
- When the server rendered a matching fragment, it is updated on the page. `{ onRendered }` is called with the [result](https://unpoly.com/api/up.RenderResult.md).
- When [revalidation](https://unpoly.com/learn/caching.md#revalidation) renders a second time, `{ onRendered }` is called again with the final result.

In HTML you can set an [`[up-on-rendered]`](https://unpoly.com/api/up-follow.md#up-on-rendered) attribute on a [link](https://unpoly.com/api/up-follow.md) or [form](https://unpoly.com/learn/submitting-forms.md):

```html
<a
  href="/foo"
  up-target=".target"
  up-on-rendered="console.log('Updated fragment is', document.querySelector('.target'))">
  Click me
</a>
```

### Awaiting postprocessing {#postprocessing}

After the [`up.render()`](https://unpoly.com/api/up.render.md) promise fulfills, fragments may still change due to asynchronous postprocessing. To run code after postprocessing has concluded, await the [`up.render().finished`](https://unpoly.com/api/up.RenderJob.prototype.finished.md) promise:

```js
let result = await up.render({ target: '.target', url: '/path' }).finished
console.log("Final fragments: ", result.fragments)
```

The following state is considered "finished":

- [Animations](https://unpoly.com/api/up.motion.md) have concluded and [transitioned](https://unpoly.com/api/up-transition.md) elements have been removed from the DOM tree.
- [Async compilers](https://unpoly.com/api/up.compiler.md#async) have settled.
- [Cached content](https://unpoly.com/learn/caching.md) has been [revalidated with the server](https://unpoly.com/learn/caching.md#revalidation).
  If the server has responded with fresher content, that content has also been rendered.

The `up.render().finished` promise resolves to the last [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md) that updated a fragment. If revalidation re-rendered the fragment, it is the result from the second render pass. If no revalidation was performed, or if revalidation yielded an [empty response](https://unpoly.com/learn/caching.md#when-nothing-changed), it is the result from the initial render pass.

The promise rejects when there is any error during the initial render pass or during revalidation.

Instead of awaiting a promise you may also pass an [`{ onFinished }`](https://unpoly.com/api/up.render.md#options.onFinished) callback. In HTML you can set an [`[up-on-finished]`](https://unpoly.com/api/up-follow.md#up-on-finished) attribute on a [link](https://unpoly.com/api/up-follow.md) or [form](https://unpoly.com/learn/submitting-forms.md).

## Changing options before rendering {#changing-options-before-rendering}

Events like [`up:link:follow`](https://unpoly.com/api/up:link:follow.md), [`up:form:submit`](https://unpoly.com/api/up:form:submit.md) and [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) carry the options of the coming render pass as `event.renderOptions`. Listeners may change these options before they take effect.

The code below opens all links within a form in an overlay, so the user's form data is not lost:

```js
up.on('up:link:follow', 'form a', function(event) {
  event.renderOptions.layer = 'new'
})
```

If you have compilers that only set default attributes, consider using a single event listener that manipulates `event.renderOptions`. It's much leaner than a compiler, which needs to run for every new fragment.

The [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) event is emitted after the server has responded, but before the response is rendered. Here you can inspect the response and change how it is rendered. The example below renders the main fragment when the server flags the response with a header:

```js
up.on('up:fragment:loaded', function(event) {
  if (event.response.header('X-Course-Completed')) {
    event.renderOptions.target = ':main'
  }
})
```

### Deferring a render pass {#deferring}

A listener can also abort a render pass, do something else, and restart it later with the original options. Assume we give a new attribute `[require-session]` to links that require a signed-in user:

```html
<a href="/projects" require-session>My projects</a>
```

When clicking the link without a session, a login form should open in a [modal overlay](https://unpoly.com/api/up.layer.md). When the user has signed in successfully, the overlay closes and the original link is followed.

We can implement this with the following handler:

```js
up.on('up:link:follow', 'a[require-session]', async function(event) {
  if (!isSignedIn()) {
    // Abort the current render pass
    event.preventDefault()

    // Wait until the user has signed in in a modal
    await up.layer.ask({ url: '/session/new', acceptLocation: '/welcome' })

    // Start a new render pass with the original render options
    up.render(event.renderOptions)
  }
})
```

## Preventing a render pass {#preventing-a-render-pass}

The [render lifecycle](#lifecycle-diagram) emits many events that you can prevent by calling `event.preventDefault()`. When an event is prevented, the render pass aborts and no elements are changed. Focus and scroll positions are kept. The [`up.render()`](https://unpoly.com/api/up.render.md) promise rejects with an `up.Aborted` error.

The most important preventable events are:

- [`up:link:follow`](https://unpoly.com/api/up:link:follow.md)
- [`up:form:submit`](https://unpoly.com/api/up:form:submit.md)
- [`up:form:validate`](https://unpoly.com/api/up:form:validate.md)
- [`up:fragment:poll`](https://unpoly.com/api/up:fragment:poll.md)
- [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md)

The last chance to prevent a render pass is [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md). It is emitted after a response was loaded, but before any elements are changed.

### Skipping a loaded response {#skipping-responses}

Even after the server has sent a response, you may decide not to render it. Call `event.skip()` on the [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) event to finish the render pass without changes. The example below skips responses that the server has flagged as unchanged:

```js
up.on('up:fragment:loaded', function(event) {
  if (event.response.header('X-Unchanged')) {
    event.skip()
  }
})
```

A skipped render pass is not an error. The [`up.render()`](https://unpoly.com/api/up.render.md) promise fulfills with an [empty](https://unpoly.com/api/up.RenderResult.prototype.none.md) [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md), and no `{ onRendered }` callback is called.

To skip responses by a global rule instead of an event listener, configure [`up.fragment.config.skipResponse`](https://unpoly.com/api/up.fragment.config.md#config.skipResponse). By default Unpoly skips the following responses:

- Responses without text in their body. Such responses occur when the server answers a [conditional request](https://unpoly.com/learn/conditional-requests.md) with `304 Not Modified`, or responds with `204 No Content`.
- When [revalidating](https://unpoly.com/learn/caching.md#revalidation), if the expired response and fresh response have the exact same text.

To abort the render pass instead, call `event.preventDefault()`. The [`up.render()`](https://unpoly.com/api/up.render.md) promise then rejects with an `up.Aborted` error. The example below shows an alert instead of rendering the response:

```js
up.on('up:fragment:loaded', function(event) {
  if (event.response.header('X-User-Created')) {
    // Abort the render pass
    event.preventDefault()

    // Show an alert instead
    alert('The user was created successfully')
  }
})
```

Preventing [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) is also the way to escape to a full page load when the server responds with an entirely different layout, like a maintenance page:

```js
up.on('up:fragment:loaded', function(event) {
  if (event.response.header('X-Maintenance')) {
    event.preventDefault()
    event.request.loadPage()
  }
})
```

See [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) for more examples.

## Handling errors {#handling-errors}

The promises returned by [`up.render()`](https://unpoly.com/api/up.render.md) and [`up.render().finished`](https://unpoly.com/api/up.RenderJob.prototype.finished.md) reject if any error is thrown during rendering, or if the server responds with an [HTTP error code](https://unpoly.com/learn/error-responses.md).

You may handle the following error cases:

| Error case | JavaScript | HTML |
| --- | --- | --- |
| Server responds with [non-2xx HTTP status](https://unpoly.com/learn/error-responses.md) | [`fail`-prefixed options](https://unpoly.com/learn/error-responses.md) | [`up-fail`-prefixed attributes](https://unpoly.com/learn/error-responses.md) |
| Server responds with [non-2xx HTTP status](https://unpoly.com/learn/error-responses.md) | [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md) (thrown) | — |
| Disconnect or timeout | [`{ onOffline }`](https://unpoly.com/learn/network-issues.md) | [`[up-on-offline]`](https://unpoly.com/learn/network-issues.md) |
| Disconnect or timeout | `up.Offline` (thrown) | — |
| Disconnect or timeout | [`up:fragment:offline`](https://unpoly.com/api/up:fragment:offline.md) | — |
| Target selector not found | [`{ fallback }`](https://unpoly.com/learn/targeting-fragments.md#missing-targets) | [`[up-fallback]`](https://unpoly.com/learn/targeting-fragments.md#missing-targets) |
| Target selector not found | `up.CannotMatch` (thrown) | — |
| Compiler throws error | Global [`error`](https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event) event | — |
| Render pass was [aborted](https://unpoly.com/learn/aborting-requests.md) | `up.Aborted` (thrown) | — |
| Render pass was [aborted](https://unpoly.com/learn/aborting-requests.md) | [`up:fragment:aborted`](https://unpoly.com/api/up:fragment:aborted.md) | — |
| Any error thrown while rendering | [`{ onError }`](https://unpoly.com/api/up.render.md#options.onError) | [`[up-on-error]`](https://unpoly.com/api/up-follow.md#up-on-error) |

An error response is rendered with `fail`-prefixed options and then _rejects_ the promise with its [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md). It does not count as an error for `{ onError }`. To run code after an error response was rendered, pass an [`{ onFailRendered }`](https://unpoly.com/learn/error-responses.md#fail-options) callback.

### Error handling example {#error-handling-example}

To demonstrate control flow in case of error, the code below handles many different error cases:

```js
window.addEventListener('error', function(event) {
  console.log('Compiler threw error', event.error)
})

try {
  let result = await up.render({
    url: '/path',
    target: '.target',    // selector to replace for 2xx status
    failTarget: '.errors' // selector to replace for non-2xx status
  })
} catch (error) {
  if (error instanceof up.RenderResult) {
    // Server sent HTML with a non-2xx status code
    console.log("Updated .errors with", error.fragments)
  } else if (error instanceof up.CannotMatch) {
    console.log("Could not find .target in current page or response")
  } else if (error instanceof up.Aborted) {
    console.log("Request was aborted")
  } else if (error instanceof up.Offline) {
    console.log("Connection loss or timeout")
  } else {
    console.log("Other error while rendering: ", error)
  }
}
```

Note how we use a `fail`-prefixed render option `{ failTarget }` to update a different fragment in case the server responds with an error code. See [Handling error responses](https://unpoly.com/learn/error-responses.md) for more details on handling server responses with an error code.

### Errors in user code {#errors-in-user-code}

Unpoly functions are generally not interrupted by errors in user code, such as compilers, transitions or callbacks.

When a user-provided function throws an exception, Unpoly instead emits an [`error` event on `window`](https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event). The operation then finishes as if no error had been thrown:

```js
up.compiler('.element', () => { throw new Error('broken compiler') })
let element = up.element.affix(document.body, '.element')
up.hello(element) // no error is thrown
```

This behavior is consistent with how the web platform handles [errors in event listeners](https://makandracards.com/makandra/481395-error-handling-in-dom-event-listeners) and custom elements.

#### Debugging and testing {#debugging-and-testing}

Exceptions in user code are also logged to the browser's [error console](https://developer.mozilla.org/en-US/docs/Web/API/console/error). This way you can still access the stack trace, and your E2E tests can fail when the browser console shows an error. Most test runners can do this, e.g. with Playwright's `page.on('console')` or the browser logs in Selenium.

Some test runners like [Jasmine](https://jasmine.github.io/) already listen to the `error` event and fail your test if any uncaught exception is observed. In Jasmine you may use [`jasmine.spyOnGlobalErrorsAsync()`](https://makandracards.com/makandra/559289-jasmine-prevent-unhandled-promise-rejection-from-failing-your-test) to make assertions on the unhandled error.

<nav aria-label="Reading order">

- [Previous: Providing HTML to render](https://unpoly.com/learn/providing-html.md)
- [Next: Extensions](https://unpoly.com/learn/extensions.md)

</nav>

