Compose Hot Reload: Make changes to your UI code in a Compose Multiplatform application, and see the results in real time

skydovesJaewoong Eum (skydoves)||78 min read

Compose Hot Reload: Make changes to your UI code in a Compose Multiplatform application, and see the results in real time

Table of Contents

  1. Introduction
  2. Architecture Overview
  3. Core Modules
  4. The Hot Reload Flow
  5. Communication Protocol
  6. State Management
  7. Static Field Re-initialization
  8. Compose Integration
  9. Window Management
  10. Advanced Topics

Introduction

When developing user interfaces, the traditional cycle of making a change, recompiling, restarting the application, and navigating back to the state you were testing can be frustrating. Each iteration can take tens of seconds, breaking your flow and making it harder to experiment with different designs. Compose Hot Reload addresses this problem by enabling real-time UI updates in Compose Multiplatform applications without requiring a full restart.

The system works by combining the JVM's HotSwap capabilities with Compose's recomposition model. When you save a file, the changes propagate to your running application within a second or two, and you can see the updated UI immediately while preserving the application's current state. This means you don't lose your place in a multi-step workflow or need to manually recreate the conditions you were testing.

The implementation involves multiple layers working together. A Java agent instruments the application's bytecode and handles class redefinition at runtime. An orchestration protocol coordinates communication between the build system and the running application. The runtime integration provides Compose-aware UI updates that only recompose the parts of your interface that actually changed. Finally, a Gradle plugin integrates everything into the build system so you can use hot reload without complicated configuration.

What Hot Reload Can Do

Hot reload supports instant UI updates when you modify composable functions, change layout logic, or update visual properties. The system preserves your application's state across reloads, so if you're testing a form with several fields filled in, those values remain after the code updates. When classes change, the system performs selective invalidation of Compose groups, recomposing only the affected parts of the UI rather than rebuilding everything. It can also re-initialize static fields when their definitions change, which is important for singleton objects and global configuration. The window state persists across reloads and even across restarts, so your window doesn't jump to a different position every time you test a change. All of this works across multiple processes, with the build system, IDE, and application coordinating through a shared orchestration layer.

Architecture Overview

The hot reload system is built from several interconnected modules, each handling a specific part of the process. Understanding how these modules work together helps explain both what hot reload can do and what its limitations are.

┌──────────────────────────────────────────────────────────┐
│                     Developer's IDE                      │
│                                                          │
│  Source Code ──> Kotlin Compiler ──> .class files        │
└────────────────────────┬─────────────────────────────────┘
                         │
                         │ File System Watch
                         ▼
┌──────────────────────────────────────────────────────────┐
│                   Gradle Plugin                          │
│                                                          │
│  • ComposeHotSnapshotTask (detect changes)               │
│  • ComposeHotReloadTask (send reload request)            │
│  • ComposeHotRun (launch with agent)                     │
└────────────────────────┬─────────────────────────────────┘
                         │
                         │ TCP Socket (Binary Protocol)
                         ▼
┌──────────────────────────────────────────────────────────┐
│                Orchestration Server                      │
│                                                          │
│  • Message Broadcasting                                  │
│  • State Management                                      │
│  • Client Coordination                                   │
└────────────────────────┬─────────────────────────────────┘
                         │
                         │ ReloadClassesRequest
                         ▼
┌──────────────────────────────────────────────────────────┐
│                    Java Agent                            │
│                                                          │
│  • Bytecode Transformation                               │
│  • Class Redefinition                                    │
│  • Static Re-initialization                              │
│  • Compose Group Invalidation                            │
└────────────────────────┬─────────────────────────────────┘
                         │
                         │ Instrumentation API
                         ▼
┌──────────────────────────────────────────────────────────┐
│                  Runtime (JVM)                           │
│                                                          │
│  • DevelopmentEntryPoint                                 │
│  • HotReloadState Management                             │
│  • Composition Reset                                     │
│  • UI Re-rendering                                       │
└──────────────────────────────────────────────────────────┘

The flow starts in your IDE, where the Kotlin compiler transforms your source code into bytecode. The Gradle plugin watches for these changes and creates snapshots of what actually changed between compilations. When changes are detected, the plugin sends a reload request through the orchestration server, which acts as a message broker between different components. The Java agent receives this request and performs the actual class redefinition using the JVM's instrumentation API. Finally, the runtime integration updates the Compose UI by triggering recomposition of the affected parts.

This architecture keeps the concerns separated. The build system handles compilation and change detection. The orchestration layer handles communication without either side needing to know the implementation details of the other. The agent handles the low-level bytecode manipulation. The runtime handles the high-level UI updates. This separation means you can understand and modify each piece independently.

Core Modules

hot-reload-agent: The Java Instrumentation Agent

The agent runs inside your application's JVM process and handles the actual class redefinition. It's loaded at startup through the Java agent mechanism, which gives it special privileges to intercept and modify class loading. The agent is the most complex part of the system because it has to handle bytecode transformation, track class loaders, coordinate with the Compose runtime, and manage all the edge cases that come with redefining classes at runtime.

Entry Point

When your application starts with hot reload enabled, the JVM calls the agent's premain function before your application's main method runs. This function initializes all the subsystems the agent needs:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/agent.kt
@file:JvmName("ComposeHotReloadAgent")

fun premain(@Suppress("unused") args: String?, instrumentation: Instrumentation) {
    startDevTools()
    startOrchestration()
    createPidfile()
    startWritingLogs()

    launchWindowInstrumentation(instrumentation)
    launchComposeInstrumentation(instrumentation)
    launchRuntimeTracking(instrumentation)
    launchReloadRequestHandler(instrumentation)
    launchJdwpTracker(instrumentation)
}

The agent first starts any development tools that might be available, then establishes a connection to the orchestration server. It creates a pidfile that contains information about how to connect to this application instance, which the Gradle plugin uses later to send reload requests. Log writing starts so you can debug issues when they occur.

Then the agent registers several class file transformers with the JVM's instrumentation API. Each transformer intercepts class loading for a specific purpose. Window instrumentation injects code to wrap your UI in the development entry point. Compose instrumentation enables hot reload mode in the Compose runtime. Runtime tracking builds a global view of all loaded classes and their relationships. The reload request handler listens for incoming reload requests and processes them. JDWP tracking monitors whether a debugger is attached, which can affect how hot reload behaves.

Key Components

RuntimeTrackingTransformer

Every time the JVM loads a class, the runtime tracking transformer gets a chance to inspect it. The transformer builds a comprehensive map of what classes exist in your application and what Compose groups they contain:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/runtimeTracking.kt
internal fun launchRuntimeTracking(instrumentation: Instrumentation) {
    val transformer = RuntimeTrackingTransformer()
    instrumentation.addTransformer(transformer, false)
}

private class RuntimeTrackingTransformer : ClassFileTransformer {
    override fun transform(
        loader: ClassLoader?,
        className: String?,
        classBeingRedefined: Class<*>?,
        protectionDomain: ProtectionDomain?,
        classfileBuffer: ByteArray
    ): ByteArray? {
        // Track class loading
        val classId = ClassId.fromSlashDelimitedFqn(className)
        classLoaders[classId] = WeakReference(loader)

        // Perform bytecode analysis
        val analysis = performComposeAnalysis(classfileBuffer)
        applicationInfo.update { current ->
            current.withClass(classId, analysis)
        }

        return null // No transformation during initial load
    }
}

The transform method receives the raw bytecode of every class as it loads. The method first extracts the class identifier from the internal JVM format, which uses slashes instead of dots to separate package names. It stores a weak reference to the class loader that loaded this class. Weak references are important here because class loaders can be garbage collected, and we don't want to prevent that by holding strong references to them.

Then the transformer performs bytecode analysis to understand what Compose groups exist in this class. This analysis involves parsing the bytecode to find calls to methods like startRestartGroup and startReplaceableGroup, which the Compose compiler inserts around composable functions. The results go into a global applicationInfo structure that tracks everything the agent knows about the application.

The transformer returns null to indicate it's not modifying the bytecode during initial loading. The modification happens later when classes are redefined during hot reload. This two-phase approach avoids potential class circularity errors that can occur if you try to transform classes too early in the JVM's initialization sequence.

ComposeTransformer

The Compose transformer watches for the Compose runtime itself to load and configures it for hot reload:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/compose.kt
private class ComposeTransformer : ClassFileTransformer {
    override fun transform(
        loader: ClassLoader?,
        className: String?,
        classBeingRedefined: Class<*>?,
        protectionDomain: ProtectionDomain?,
        classfileBuffer: ByteArray
    ): ByteArray? {
        if (className == "androidx/compose/runtime/Recomposer\$Companion") {
            // Enable hot reload mode
            Recomposer.Companion::class.java
                .getMethod("setHotReloadEnabled", Boolean::class.java)
                .invoke(null, true)

            // Set up group invalidation
            Recomposer.Companion::class.java
                .getMethod("setGroupInvalidator", Function1::class.java)
                .invoke(null, groupInvalidator)
        }
        return null
    }
}

When the Recomposer companion object loads, the transformer calls setHotReloadEnabled(true) on it. The Recomposer is the core of Compose's runtime, responsible for scheduling recomposition and managing the composition tree. Enabling hot reload mode changes how it handles recomposition, allowing the agent to trigger targeted recomposition of specific groups rather than rebuilding the entire UI.

The transformer also installs a group invalidator callback. After class redefinition, the agent will call this callback with information about which Compose groups need to be recomposed. The Recomposer uses this information to invalidate only the affected groups, avoiding unnecessary recomposition of UI elements that didn't change.

WindowInstrumentation

The window instrumentation transformer wraps your UI in a development entry point without requiring you to modify your application code:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/window.kt
private class WindowInstrumentation : ClassFileTransformer {
    override fun transform(
        loader: ClassLoader?,
        className: String?,
        classBeingRedefined: Class<*>?,
        protectionDomain: ProtectionDomain?,
        classfileBuffer: ByteArray
    ): ByteArray {
        if (className == "androidx/compose/ui/awt/ComposeWindow") {
            return transformComposeWindow(classfileBuffer)
        }
        return classfileBuffer
    }

    private fun transformComposeWindow(bytecode: ByteArray): ByteArray {
        val clazz = ClassPool.getDefault().makeClass(bytecode.inputStream())

        // Redirect setContent() to DevelopmentEntryPoint
        clazz.getDeclaredMethod("setContent").apply {
            insertBefore("""
                org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint
                    .setContent(this, $$1, $$2, $$3);
                return;
            """)
        }

        return clazz.toBytecode()
    }
}

Every Compose Desktop application calls setContent on a ComposeWindow to define its UI. The window instrumentation intercepts this call and redirects it through the DevelopmentEntryPoint, which adds hot reload support. The instrumentation uses Javassist to insert bytecode at the beginning of the setContent method that calls the development entry point instead of the original implementation.

The inserted code receives all the same parameters as the original method using Javassist's $$ syntax, which expands to the list of method parameters. After calling the development entry point, the instrumented method returns immediately, preventing the original implementation from running. This approach works transparently without requiring developers to change their application code.

Reload Request Handler

When the build system detects changes and sends a reload request, the reload request handler processes it:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/reloadRequestHandler.kt
internal fun launchReloadRequestHandler(instrumentation: Instrumentation) {
    orchestration.asFlow()
        .filterIsInstance<ReloadClassesRequest>()
        .onEach { request ->
            // Ensure reload happens on UI thread
            SwingUtilities.invokeAndWait {
                val result = performReload(instrumentation, request)

                // Send result back
                ReloadClassesResult(
                    reloadRequestId = request.messageId,
                    isSuccess = result.isSuccess,
                    errorMessage = result.errorMessage,
                    errorStacktrace = result.errorStacktrace
                ).sendBlocking()
            }
        }
        .launchIn(reloadScope)
}

The handler listens to the orchestration message flow and filters for reload class requests. When one arrives, it schedules the reload to happen on the Swing Event Dispatch Thread. Running on the EDT is crucial because the JVM's class redefinition mechanism can cause problems if you try to redefine classes while they're actively being used on other threads. By doing all the work on the EDT, we ensure that any Compose UI code is idle during the redefinition process.

The invokeAndWait call blocks the handler's coroutine until the reload completes on the EDT. This blocking behavior is intentional because the handler needs to send back a result message indicating success or failure, and it can't do that until the reload actually finishes. After the reload completes, the handler sends a ReloadClassesResult message back through the orchestration, which the build system receives and displays to the developer.

Class Reloading Logic

The actual reload process involves several steps, all coordinated by the reload function:

// hot-reload-agent/src/main/kotlin/org/jetbrains/compose/reload/agent/reload.kt
internal fun Context.reload(
    instrumentation: Instrumentation,
    reloadRequestId: OrchestrationMessageId,
    pendingChanges: Map<File, ReloadClassesRequest.ChangeType>
): Try<Reload> = Try {

    val definitions = pendingChanges.mapNotNull { (file, change) ->
        if (change == ReloadClassesRequest.ChangeType.Removed) {
            return@mapNotNull null
        }

        // Read the new bytecode
        val code = file.readBytes()
        val classId = ClassId(code) ?: return@mapNotNull null

        // Find the appropriate ClassLoader
        val loader = findClassLoader(classId).get() ?: return@mapNotNull null

        // Load original class
        val originalClass = loader.loadClass(classId.toFqn())

        // Transform bytecode with Javassist
        val clazz = getClassPool(loader).makeClass(code.inputStream())

        // Add static re-initialization support
        clazz.transformForStaticsInitialization(originalClass)

        // Convert to bytecode
        val transformed = clazz.toBytecode()

        ClassDefinition(originalClass, transformed)
    }

    // Apply the redefinitions
    instrumentation.redefineClasses(*definitions.toTypedArray())

    // Resolve dirty Compose scopes
    val dirtyScopes = resolveDirtyScopes(definitions)

    // Re-initialize static fields
    reinitializeStaticsIfNecessary(definitions)

    // Invalidate Compose groups
    invalidateComposeGroups(dirtyScopes)

    Reload(reloadRequestId, definitions, dirtyScopes)
}

The reload function processes each changed file individually. For removed files, there's nothing to do since you can't unload classes from the JVM. For added or modified files, the function reads the new bytecode from disk and extracts the class identifier from it. The identifier tells us which class this bytecode represents without having to parse the entire class file.

Finding the right class loader is surprisingly tricky. Java applications can have many class loaders forming a hierarchy, and each class must be redefined using the same loader that originally loaded it. The findClassLoader function searches through all the tracked loaders to find one that has loaded this specific class. If the class hasn't been loaded yet, the function returns null and skips this file. There's no point in redefining a class that isn't loaded yet since it will get the new version naturally when it loads.

Once we have the class loader, we can load the original class definition and start transforming the new bytecode. Javassist provides a higher-level API than raw ASM for bytecode manipulation. The transformation adds support for static re-initialization, which we'll cover in detail later. After transformation, the bytecode goes into a ClassDefinition object that pairs the original class with its new bytecode.

The call to redefineClasses is where the actual class replacement happens. This JVM operation atomically replaces the method implementations in all the specified classes. The replacement happens immediately, so any code that calls these methods after this point will execute the new implementation. The JVM has strict rules about what can change during redefinition. You can change method bodies, but you can't add or remove methods, add or remove fields, or change class hierarchies. These limitations are why some changes require a full application restart.

After redefinition, the function resolves which Compose groups were affected by the changes. A Compose group represents a section of UI defined by a composable function. The resolution process analyzes the changed classes to determine which groups they contain and marks those groups as dirty. The group invalidation step tells the Compose runtime to recompose these dirty groups, causing the UI to update with the new code.

Static field re-initialization handles the case where static initialization code changed. The JVM doesn't automatically re-run static initializers when you redefine a class, so the agent has to do it explicitly. This process is complex enough to deserve its own section later in this document.

hot-reload-orchestration: Communication Backbone

The orchestration module provides the communication layer that connects all the pieces of hot reload together. Instead of having the Gradle plugin directly communicate with the agent, or having multiple point-to-point connections between different components, everything goes through a central orchestration server. This design makes it easier to add new tools and features because they just need to know how to talk to the orchestration server, not to every other component in the system.

Protocol Design

The orchestration uses a custom binary protocol running over TCP sockets. The protocol is simple but sufficient for the needs of hot reload:

// hot-reload-orchestration/src/main/kotlin/org/jetbrains/compose/reload/orchestration/protocol.kt
object OrchestrationProtocol {
    const val MAGIC_NUMBER = 24111602
    const val CURRENT_VERSION = 3

    // Frame format:
    // [int: MessageType][int: MessageSize][bytes[]: MessageBinary]

    fun writeMessage(output: DataOutputStream, message: OrchestrationMessage) {
        val bytes = serialize(message)
        output.writeInt(message.javaClass.hashCode())
        output.writeInt(bytes.size)
        output.write(bytes)
        output.flush()
    }

    fun readMessage(input: DataInputStream): OrchestrationMessage {
        val messageType = input.readInt()
        val messageSize = input.readInt()
        val bytes = ByteArray(messageSize)
        input.readFully(bytes)
        return deserialize(bytes)
    }
}

Each message starts with two integers: a message type identifier and the size of the message body. The type identifier is just the hash code of the message class, which provides a stable way to identify message types across different JVM processes. After the header comes the serialized message data itself, which uses Java's built-in serialization mechanism.

The magic number and version number appear during the connection handshake. The magic number provides a sanity check that you're really talking to an orchestration server and not some other service that happens to be listening on that port. The version number allows for protocol evolution over time. If the protocol needs to change in incompatible ways, the version number can increment, and older clients can detect that they're talking to a newer server or vice versa.

Message Types

The protocol defines a variety of message types for different situations. Understanding these messages helps clarify what operations hot reload supports and how different components coordinate:

// hot-reload-orchestration/src/main/kotlin/org/jetbrains/compose/reload/orchestration/OrchestrationMessage.kt
public abstract class OrchestrationMessage : Serializable {

    // Lifecycle messages
    public data class ClientConnected(
        val clientId: OrchestrationClientId,
        val clientRole: OrchestrationClientRole,
        val clientPid: Long? = null
    ) : OrchestrationMessage()

    public data class ClientDisconnected(
        val clientId: OrchestrationClientId,
        val clientRole: OrchestrationClientRole
    ) : OrchestrationMessage()

    // Reload messages
    public data class ReloadClassesRequest(
        val changedClassFiles: Map<File, ChangeType> = emptyMap()
    ) : OrchestrationMessage() {
        public enum class ChangeType : Serializable {
            Modified, Added, Removed
        }
    }

    public data class ReloadClassesResult(
        val reloadRequestId: OrchestrationMessageId,
        val isSuccess: Boolean,
        val errorMessage: String? = null,
        val errorStacktrace: List<StackTraceElement>? = null
    ) : OrchestrationMessage()

    // Compose-specific messages
    public data class InvalidatedComposeGroupMessage(
        val groupKey: Int,
        val dirtyScopes: List<DirtyScope>
    ) : OrchestrationMessage()

    // UI state messages
    public data class UIRendered(
        val windowId: WindowId?,
        val reloadRequestId: OrchestrationMessageId?,
        val iteration: Int
    ) : OrchestrationMessage()

    public class UIException(
        val windowId: WindowId?,
        val message: String?,
        val stacktrace: List<StackTraceElement>
    ) : OrchestrationMessage()

    // Control messages
    public data class ShutdownRequest(
        val reason: String? = null,
        val pidFile: File? = null,
        val pid: Long? = null
    ) : OrchestrationMessage()

    public class CleanCompositionRequest : OrchestrationMessage()

    public class RetryFailedCompositionRequest : OrchestrationMessage()
}

Lifecycle messages track when clients connect and disconnect from the orchestration. Each client has a unique identifier and a role that describes what it does. The compiler role represents the build system sending reload requests. The application role represents the running application receiving those requests. The DevTools role represents development tools that want to observe or control the reload process.

The reload messages form the core of the protocol. A ReloadClassesRequest contains a map of files that changed and whether each one was added, modified, or removed. The agent processes this request and responds with a ReloadClassesResult indicating success or failure. If the reload failed, the result includes an error message and stack trace to help diagnose the problem.

Compose-specific messages provide visibility into what hot reload is doing. The InvalidatedComposeGroupMessage reports which UI groups were invalidated during a reload. Development tools can use this information to show which parts of the UI are recomposing. The UIRendered message indicates successful UI rendering after a reload, providing feedback that the change actually took effect.

UI state messages help detect problems. The UIException message reports exceptions that occurred during composition. When your UI code throws an exception, the development entry point catches it and sends this message so the build system can display it. This mechanism provides much better error reporting than just letting the exception propagate, since it can include context about which reload triggered the error.

Control messages provide higher-level operations. The ShutdownRequest tells the application to shut down gracefully, which is useful when you want to restart from a clean state. The CleanCompositionRequest discards all remembered state and forces a complete recomposition, which can help recover from certain types of errors. The RetryFailedCompositionRequest attempts to recompose after a previous failure, useful when you've fixed a bug that was causing composition to crash.

Orchestration Server

The orchestration server accepts connections from multiple clients and broadcasts messages between them:

// hot-reload-orchestration/src/main/kotlin/org/jetbrains/compose/reload/orchestration/OrchestrationServer.kt
public class OrchestrationServer(
    private val port: Int = 0
) : Closeable {

    private val serverSocket = ServerSocket(port)
    private val clients = ConcurrentHashMap<OrchestrationClientId, ClientConnection>()

    init {
        // Accept connections in background
        acceptorScope.launch {
            while (isActive) {
                val socket = serverSocket.accept()
                handleNewConnection(socket)
            }
        }
    }

    private suspend fun handleNewConnection(socket: Socket) {
        val connection = ClientConnection(socket)

        // Perform handshake
        connection.performHandshake()

        // Register client
        clients[connection.clientId] = connection

        // Broadcast connection event
        broadcast(ClientConnected(connection.clientId, connection.role))

        // Start message processing
        connection.startMessageProcessing { message ->
            broadcast(message)
        }
    }

    suspend fun broadcast(message: OrchestrationMessage) {
        clients.values.forEach { client ->
            client.send(message)
        }
    }
}

The server runs in a background coroutine that accepts new connections in a loop. When a client connects, the server performs a handshake to verify the protocol version and exchange initial information. After handshaking, the server registers the client in its map of active connections and broadcasts a ClientConnected message to all other clients.

Each client connection has its own message processing loop that reads messages from the socket and calls a callback function. The server provides a callback that broadcasts received messages to all connected clients. This broadcast model means that when the Gradle plugin sends a reload request, the server forwards it to the agent automatically. When the agent sends back a result, the server forwards it to the Gradle plugin. Tools that want to observe the reload process can connect as additional clients and receive all these messages without interfering with the core functionality.

The broadcast approach has some interesting implications. Messages are visible to all clients regardless of who sent them. This transparency makes it easy to add new tools that observe hot reload behavior. However, it also means that any client can send any message, so clients need to be prepared to receive messages they don't understand. The protocol handles this through opaque message support, where unknown message types are passed through without parsing.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor