Index: /trunk/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java	(revision 19176)
+++ /trunk/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java	(revision 19176)
@@ -0,0 +1,69 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+
+import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
+import org.openstreetmap.josm.data.osm.visitor.paint.StyledMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.StyledTiledMapRenderer;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.tools.Shortcut;
+
+/**
+ * This class enables and disables tiled rendering mode.
+ * This is intended to be short-term until the tiled rendering
+ * has no significant issues at high zoom levels.
+ * @since 19176
+ */
+public class TiledRenderToggleAction extends ToggleAction implements ExpertToggleAction.ExpertModeChangeListener {
+    /**
+     * Create a new action for toggling render methods
+     */
+    public TiledRenderToggleAction() {
+        super(tr("Tiled Rendering"),
+                null,
+                tr("Enable/disable rendering the map in tiles"),
+                Shortcut.registerShortcut("menu:view:tiled", tr("View: {0}", tr("Tiled View")), KeyEvent.CHAR_UNDEFINED, Shortcut.NONE),
+                false /* register toolbar */
+            );
+        setToolbarId("tiledRendering");
+        MainApplication.getToolbar().register(this);
+        setSelected(false); // Always start disabled (until we are confident in the renderer)
+        if (MapRendererFactory.getInstance().isMapRendererActive(StyledTiledMapRenderer.class)) {
+            MapRendererFactory.getInstance().activate(StyledMapRenderer.class);
+        }
+        ExpertToggleAction.addExpertModeChangeListener(this, true);
+    }
+
+    @Override
+    protected boolean listenToSelectionChange() {
+        return false;
+    }
+
+    @Override
+    public void expertChanged(boolean isExpert) {
+        this.updateEnabledState();
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(getLayerManager().getActiveData() != null && ExpertToggleAction.isExpert());
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        toggleSelectedState(e);
+        if (isSelected()) {
+            MapRendererFactory.getInstance().activate(StyledTiledMapRenderer.class);
+        } else {
+            MapRendererFactory.getInstance().activate(StyledMapRenderer.class);
+        }
+
+        notifySelectedState();
+        getLayerManager().getLayersOfType(OsmDataLayer.class).forEach(OsmDataLayer::invalidate);
+    }
+}
Index: /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java	(revision 19176)
+++ /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java	(revision 19176)
@@ -0,0 +1,68 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.visitor.paint;
+
+import java.awt.Image;
+
+import jakarta.annotation.Nullable;
+
+/**
+ * A record for keeping the image information for a tile. Used in conjunction with {@link TileZXY} for
+ * {@link org.openstreetmap.josm.data.cache.JCSCacheManager}.
+ * @since 19176
+ */
+public final class ImageCache {
+    private final boolean isDirty;
+    private final StyledTiledMapRenderer.TileLoader imageFuture;
+    private final Image image;
+    /**
+     * Create a new {@link ImageCache} object
+     * @param image The image to paint (optional; either this or {@link #imageFuture} must be specified)
+     * @param imageFuture The future for the image (optional; either this or {@link #image} must be specified)
+     * @param isDirty {@code true} if the tile needs to be repainted
+     */
+    ImageCache(Image image, StyledTiledMapRenderer.TileLoader imageFuture, boolean isDirty) {
+        this.image = image;
+        this.imageFuture = imageFuture;
+        this.isDirty = isDirty;
+        if (image == null && imageFuture == null) {
+            throw new IllegalArgumentException("Either image or imageFuture must be non-null");
+        }
+    }
+
+    /**
+     * Check if this tile is dirty
+     * @return {@code true} if this is a dirty tile
+     */
+    public boolean isDirty() {
+        return this.isDirty;
+    }
+
+    /**
+     * Mark this tile as dirty
+     * @return The tile to put in the cache
+     */
+    public ImageCache becomeDirty() {
+        if (this.isDirty) {
+            return this;
+        }
+        return new ImageCache(this.image, this.imageFuture, true);
+    }
+
+    /**
+     * Get the image to paint
+     * @return The image (may be {@code null})
+     */
+    @Nullable
+    public Image image() {
+        return this.image;
+    }
+
+    /**
+     * Get the image future
+     * @return The image future (may be {@code null})
+     */
+    @Nullable
+    StyledTiledMapRenderer.TileLoader imageFuture() {
+        return this.imageFuture;
+    }
+}
Index: /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java	(revision 19175)
+++ /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java	(revision 19176)
@@ -192,4 +192,9 @@
                 tr("Renders the map using style rules in a set of style sheets.")
         );
+        register(
+                StyledTiledMapRenderer.class,
+                tr("Styled Map Renderer (tiled)"),
+                tr("Renders the map using style rules in a set of style sheets by tile.")
+        );
     }
 
@@ -319,5 +324,16 @@
      */
     public boolean isWireframeMapRendererActive() {
-        return WireframeMapRenderer.class.equals(activeRenderer);
+        return isMapRendererActive(WireframeMapRenderer.class);
+    }
+
+    /**
+     * <p>Replies true, if currently the specified map renderer is active. Otherwise, false.</p>
+     *
+     * @param clazz The class that we are checking to see if it is the current renderer
+     * @return true, if currently the wireframe map renderer is active. Otherwise, false
+     * @since 19176
+     */
+    public boolean isMapRendererActive(Class<? extends AbstractMapRenderer> clazz) {
+        return clazz.equals(activeRenderer);
     }
 }
Index: /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java	(revision 19176)
+++ /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java	(revision 19176)
@@ -0,0 +1,350 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.visitor.paint;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.AlphaComposite;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.Transparency;
+import java.awt.event.MouseEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.image.BufferedImage;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.IntSummaryStatistics;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import org.apache.commons.jcs3.access.CacheAccess;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.NavigatableComponent;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A styled render that does the rendering on a tile basis. Note: this is currently experimental!
+ * It may be extracted to an interface at a later date.
+ * @since 19176
+ */
+public final class StyledTiledMapRenderer extends StyledMapRenderer {
+    // Render to the surrounding tiles for continuity -- this probably needs to be tweaked
+    private static final int BUFFER_TILES = 2;
+    // The number of extra pixels to render per tile (avoids black lines in render result)
+    private static final int BUFFER_PIXELS = 16;
+    private CacheAccess<TileZXY, ImageCache> cache;
+    private int zoom;
+    private Consumer<TileZXY> notifier;
+
+    /**
+     * Constructs a new {@code StyledMapRenderer}.
+     *
+     * @param g              the graphics context. Must not be null.
+     * @param nc             the map viewport. Must not be null.
+     * @param isInactiveMode if true, the paint visitor shall render OSM objects such that they
+     *                       look inactive. Example: rendering of data in an inactive layer using light gray as color only.
+     * @throws IllegalArgumentException if {@code g} is null
+     * @throws IllegalArgumentException if {@code nc} is null
+     */
+    public StyledTiledMapRenderer(Graphics2D g, NavigatableComponent nc, boolean isInactiveMode) {
+        super(g, nc, isInactiveMode);
+    }
+
+    @Override
+    public void render(OsmData<?, ?, ?, ?> data, boolean renderVirtualNodes, Bounds bounds) {
+        // If there is no cache, fall back to old behavior
+        if (this.cache == null) {
+            super.render(data, renderVirtualNodes, bounds);
+            return;
+        }
+        final Executor worker = MainApplication.worker;
+        final BufferedImage tempImage;
+        final Graphics2D tempG2d;
+        // I'd like to avoid two image copies, but there are some issues using the original g2d object
+        tempImage = nc.getGraphicsConfiguration().createCompatibleImage(this.nc.getWidth(), this.nc.getHeight(), Transparency.TRANSLUCENT);
+        tempG2d = tempImage.createGraphics();
+        tempG2d.setComposite(AlphaComposite.DstAtop); // Avoid tile lines in large areas
+
+        final List<TileZXY> toRender = TileZXY.boundsToTiles(bounds.getMinLat(), bounds.getMinLon(),
+                bounds.getMaxLat(), bounds.getMaxLon(), zoom).collect(Collectors.toList());
+        final Bounds box = new Bounds(bounds);
+        toRender.stream().map(TileZXY::tileToBounds).forEach(box::extend);
+        final int tileSize;
+        if (toRender.isEmpty()) {
+            tileSize = Config.getPref().getInt("mappaint.fast_render.tile_size", 256); // Mostly to keep the compiler happy
+        } else {
+            final TileZXY tile = toRender.get(0);
+            final Bounds box2 = TileZXY.tileToBounds(tile);
+            final Point min = this.nc.getPoint(box2.getMin());
+            final Point max = this.nc.getPoint(box2.getMax());
+            tileSize = max.x - min.x + BUFFER_PIXELS;
+        }
+
+        // Sort the tiles based off of proximity to the mouse pointer
+        if (nc instanceof MapView) { // Ideally this would either be an interface or a method in NavigableComponent
+            final MapView mv = (MapView) nc;
+            final MouseEvent mouseEvent = mv.lastMEvent;
+            final LatLon mousePosition = nc.getLatLon(mouseEvent.getX(), mouseEvent.getY());
+            final TileZXY mouseTile = TileZXY.latLonToTile(mousePosition.lat(), mousePosition.lon(), zoom);
+            toRender.sort(Comparator.comparingInt(tile -> {
+                final int x = tile.x() - mouseTile.x();
+                final int y = tile.y() - mouseTile.y();
+                return x * x + y * y;
+            }));
+        }
+
+        // We want to prioritize where the mouse is, but having some in the queue will reduce overall paint time
+        int submittedTile = 5;
+        int painted = 0;
+        for (TileZXY tile : toRender) {
+            final Image tileImage;
+            // Needed to avoid having tiles that aren't rendered properly
+            final ImageCache tImg = this.cache.get(tile);
+            final boolean wasDirty = tImg != null && tImg.isDirty();
+            if (tImg != null && !tImg.isDirty() && tImg.imageFuture() != null) {
+                submittedTile = 0; // Don't submit new tiles if there are futures already in the queue. Not perfect.
+            }
+            if (submittedTile > 0 && (tImg == null || tImg.isDirty())) {
+                // Ensure that we don't add a large number of render calls
+                if (tImg != null && tImg.imageFuture() != null) {
+                    tImg.imageFuture().cancel();
+                }
+                submittedTile--;
+                // Note that the paint code is *not* thread safe, so all tiles must be painted on the same thread.
+                // FIXME figure out how to make this thread safe? Probably not necessary, since UI isn't blocked, but it would be a nice to have
+                TileLoader loader = new TileLoader(data, tile, tileSize, new ArrayList<>());
+                worker.execute(loader);
+                if (tImg == null) {
+                    this.cache.put(tile, new ImageCache(null, loader, false));
+                } else {
+                    // This might cause some extra renders, but *probably* ok
+                    this.cache.put(tile, new ImageCache(tImg.image(), loader, true));
+                }
+                tileImage = tImg != null ? tImg.image() : null;
+            } else if (tImg != null) {
+                tileImage = tImg.image();
+            } else {
+                tileImage = null;
+            }
+            final Point point = this.nc.getPoint(tile);
+            if (tileImage != null) {
+                if ((wasDirty && Logging.isTraceEnabled()) || this.isInactiveMode) {
+                    tempG2d.setColor(Color.DARK_GRAY);
+                    tempG2d.fillRect(point.x, point.y, tileSize, tileSize);
+                } else {
+                    painted++;
+                }
+                // There seems to be an off-by-one error somewhere.
+                tempG2d.drawImage(tileImage, point.x + 1, point.y + 1, null, null);
+            } else {
+                Logging.trace("StyledMapRenderer did not paint tile {1}", tile);
+            }
+        }
+        // Force another render pass if there may be more tiles to render
+        if (submittedTile <= 0) {
+            worker.execute(nc::invalidate);
+        }
+        final double percentDrawn = 100 * painted / (double) toRender.size();
+        if (percentDrawn < 99.99) {
+            final int x = 0;
+            final int y = nc.getHeight() / 8;
+            final String message = tr("Rendering Status: {0}%", Math.floor(percentDrawn));
+            tempG2d.setComposite(AlphaComposite.SrcOver);
+            tempG2d.setFont(new Font("sansserif", Font.BOLD, 13));
+            tempG2d.setColor(Color.BLACK);
+            tempG2d.drawString(message, x + 1, y);
+            tempG2d.setColor(Color.LIGHT_GRAY);
+            tempG2d.drawString(message, x, y);
+        }
+        tempG2d.dispose();
+        g.drawImage(tempImage, 0, 0, null);
+    }
+
+    /**
+     * Set the cache for this painter. If not set, this acts like {@link StyledMapRenderer}.
+     * @param box The box we will be rendering -- any jobs for tiles outside of this box will be cancelled
+     * @param cache The cache to use
+     * @param zoom The zoom level to use for creating the tiles
+     * @param notifier The method to call when a tile has been updated. This may or may not be called in the EDT.
+     */
+    public void setCache(Bounds box, CacheAccess<TileZXY, ImageCache> cache, int zoom, Consumer<TileZXY> notifier) {
+        this.cache = cache;
+        this.zoom = zoom;
+        this.notifier = notifier != null ? notifier : tile -> { /* Do nothing */ };
+
+        Set<TileZXY> tiles = TileZXY.boundsToTiles(box.getMinLat(), box.getMinLon(), box.getMaxLat(), box.getMaxLon(), zoom)
+                .collect(Collectors.toSet());
+        cache.getMatching(".*").forEach((key, value) -> {
+            if (!tiles.contains(key)) {
+                cancelImageFuture(cache, key, value);
+            }
+        });
+    }
+
+    /**
+     * Cancel a job for a tile
+     * @param cache The cache with the job
+     * @param key The tile key
+     * @param value The {@link ImageCache} to remove and cancel
+     */
+    private static void cancelImageFuture(CacheAccess<TileZXY, ImageCache> cache, TileZXY key, ImageCache value) {
+        if (value.imageFuture() != null) {
+            value.imageFuture().cancel();
+            if (value.image() == null) {
+                cache.remove(key);
+            } else {
+                cache.put(key, new ImageCache(value.image(), null, value.isDirty()));
+            }
+        }
+    }
+
+    /**
+     * Generate tile images
+     * @param data The data to generate tiles from
+     * @param tiles The collection of tiles to generate (note: there is currently a bug with multiple tiles)
+     * @param tileSize The size of the tile image
+     * @return The image for the tiles passed in
+     */
+    private BufferedImage generateTiles(OsmData<?, ?, ?, ?> data, Collection<TileZXY> tiles, int tileSize) {
+        if (tiles.isEmpty()) {
+            throw new IllegalArgumentException("tiles cannot be empty");
+        }
+        // We need to know how large of an area we are rendering; we get the min x/y and max x/y in order to get the
+        // number of tiles in the x/y directions we are rendering.
+        final IntSummaryStatistics xStats = tiles.stream().mapToInt(TileZXY::x).distinct().summaryStatistics();
+        final IntSummaryStatistics yStats = tiles.stream().mapToInt(TileZXY::y).distinct().summaryStatistics();
+        final int xCount = xStats.getMax() - xStats.getMin() + 1; // inclusive
+        final int yCount = yStats.getMax() - yStats.getMin() + 1; // inclusive
+        final int width = tileSize * (2 * BUFFER_TILES + xCount);
+        final int height = tileSize * (2 * BUFFER_TILES + yCount);
+        // getWidth and getHeight are called in the constructor; Java 22 will let us call super after we set variables.
+        final NavigatableComponent temporaryView = new NavigatableComponent() {
+            @Override
+            public int getWidth() {
+                return width;
+            }
+
+            @Override
+            public int getHeight() {
+                return height;
+            }
+        };
+        // These bounds are used to set the render area; it includes the buffer area.
+        final Bounds bounds = generateRenderArea(tiles);
+
+        temporaryView.zoomTo(bounds.getCenter().getEastNorth(ProjectionRegistry.getProjection()), mapState.getScale());
+        BufferedImage bufferedImage = Optional.ofNullable(nc.getGraphicsConfiguration())
+                .map(gc -> gc.createCompatibleImage(tileSize * xCount + xCount, tileSize * yCount + xCount, Transparency.TRANSLUCENT))
+                .orElseGet(() -> new BufferedImage(tileSize * xCount + xCount, tileSize * yCount + xCount, BufferedImage.TYPE_INT_ARGB));
+        Graphics2D g2d = bufferedImage.createGraphics();
+        try {
+            g2d.setRenderingHints(Map.of(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON));
+            g2d.setTransform(AffineTransform.getTranslateInstance(-BUFFER_TILES * (double) tileSize, -BUFFER_TILES * (double) tileSize));
+            final AbstractMapRenderer tilePainter = MapRendererFactory.getInstance().createActiveRenderer(g2d, temporaryView, false);
+            tilePainter.render(data, true, bounds);
+        } finally {
+            g2d.dispose();
+        }
+        return bufferedImage;
+    }
+
+    /**
+     * Generate the area for rendering
+     * @param tiles The tiles that we want to render
+     * @return The generated render area with {@link #BUFFER_TILES} on all sides.
+     */
+    private static Bounds generateRenderArea(Collection<TileZXY> tiles) {
+        Bounds bounds = null;
+        for (TileZXY tile : tiles) {
+            if (bounds == null) {
+                bounds = TileZXY.tileToBounds(tile);
+            }
+            bounds.extend(TileZXY.tileToBounds(new TileZXY(tile.zoom(), tile.x() - BUFFER_TILES, tile.y() - BUFFER_TILES)));
+            bounds.extend(TileZXY.tileToBounds(new TileZXY(tile.zoom(), tile.x() + BUFFER_TILES, tile.y() + BUFFER_TILES)));
+        }
+        return Objects.requireNonNull(bounds);
+    }
+
+    /**
+     * A loader for tiles
+     */
+    class TileLoader implements Runnable {
+        private final TileZXY tile;
+        private final int tileSize;
+        private final OsmData<?, ?, ?, ?> data;
+        private boolean cancel;
+        private final Collection<TileLoader> tileCollection;
+        private boolean done;
+
+        /**
+         * Create a new tile loader
+         * @param data The data to use for painting
+         * @param tile The tile this tile loader is for
+         * @param tileSize The expected size of this tile
+         * @param tileCollection The collection of tiles that this tile is being rendered with (for batching)
+         */
+        TileLoader(OsmData<?, ?, ?, ?> data, TileZXY tile, int tileSize, Collection<TileLoader> tileCollection) {
+            this.data = data;
+            this.tile = tile;
+            this.tileSize = tileSize;
+            this.tileCollection = tileCollection;
+            this.tileCollection.add(this);
+        }
+
+        @Override
+        public void run() {
+            if (!cancel) {
+                synchronized (tileCollection) {
+                    if (!done) {
+                        final BufferedImage tImage = generateTiles(data,
+                                tileCollection.stream().map(t -> t.tile).collect(Collectors.toList()), tileSize);
+                        final int minX = tileCollection.stream().map(t -> t.tile).mapToInt(TileZXY::x).min().orElse(this.tile.x());
+                        final int minY = tileCollection.stream().map(t -> t.tile).mapToInt(TileZXY::y).min().orElse(this.tile.y());
+                        for (TileLoader loader : tileCollection) {
+                            final TileZXY txy = loader.tile;
+                            final int x = (txy.x() - minX) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2;
+                            final int y = (txy.y() - minY) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2;
+                            final int wh = tileSize - BUFFER_PIXELS / 2;
+
+                            final BufferedImage tileImage = tImage.getSubimage(x, y, wh, wh);
+                            loader.cacheTile(tileImage);
+                        }
+                    }
+                }
+            }
+        }
+
+        /**
+         * Finish a tile generation job
+         * @param tImage The tile image for this job
+         */
+        private synchronized void cacheTile(BufferedImage tImage) {
+            cache.put(tile, new ImageCache(tImage, null, false));
+            done = true;
+            notifier.accept(tile);
+        }
+
+        /**
+         * Cancel this job without causing a {@link java.util.concurrent.CancellationException}
+         */
+        void cancel() {
+            this.cancel = true;
+        }
+    }
+}
Index: /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java	(revision 19176)
+++ /trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java	(revision 19176)
@@ -0,0 +1,169 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.visitor.paint;
+
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.coor.ILatLon;
+
+/**
+ * A record used for storing tile information for painting
+ * @since 19176
+ */
+public final class TileZXY implements ILatLon {
+    private final int zoom;
+    private final int x;
+    private final int y;
+
+    /**
+     * Create a new {@link TileZXY} object
+     * @param zoom The zoom for which this tile was created
+     * @param x The x coordinate at the specified zoom level
+     * @param y The y coordinate at the specified zoom level
+     */
+    public TileZXY(int zoom, int x, int y) {
+        this.zoom = zoom;
+        this.x = x;
+        this.y = y;
+    }
+
+    /**
+     * Get the zoom level
+     * @return The zoom level for which this tile was created
+     */
+    public int zoom() {
+        return this.zoom;
+    }
+
+    /**
+     * Get the x coordinate
+     * @return The x coordinate for this tile
+     */
+    public int x() {
+        return this.x;
+    }
+
+    /**
+     * Get the y coordinate
+     * @return The y coordinate for this tile
+     */
+    public int y() {
+        return this.y;
+    }
+
+    /**
+     * Get the latitude for upper-left corner of this tile
+     * @return The latitude
+     */
+    @Override
+    public double lat() {
+        return yToLat(this.y(), this.zoom());
+    }
+
+    /**
+     * Get the longitude for the upper-left corner of this tile
+     * @return The longitude
+     */
+    @Override
+    public double lon() {
+        return xToLon(this.x(), this.zoom());
+    }
+
+    /**
+     * Convert a bounds to a series of tiles that entirely cover the bounds
+     * @param minLat The minimum latitude
+     * @param minLon The minimum longitude
+     * @param maxLat The maximum latitude
+     * @param maxLon The maximum longitude
+     * @param zoom The zoom level to generate the tiles for
+     * @return The stream of tiles
+     */
+    public static Stream<TileZXY> boundsToTiles(double minLat, double minLon, double maxLat, double maxLon, int zoom) {
+        return boundsToTiles(minLat, minLon, maxLat, maxLon, zoom, 0);
+    }
+
+    /**
+     * Convert a bounds to a series of tiles that entirely cover the bounds
+     * @param minLat The minimum latitude
+     * @param minLon The minimum longitude
+     * @param maxLat The maximum latitude
+     * @param maxLon The maximum longitude
+     * @param zoom The zoom level to generate the tiles for
+     * @param expansion The number of tiles to expand on the x/y axis (1 row north, 1 row south, 1 column left, 1 column right)
+     * @return The stream of tiles
+     */
+    public static Stream<TileZXY> boundsToTiles(double minLat, double minLon, double maxLat, double maxLon, int zoom, int expansion) {
+        final TileZXY upperRight = latLonToTile(maxLat, maxLon, zoom);
+        final TileZXY lowerLeft = latLonToTile(minLat, minLon, zoom);
+        return IntStream.rangeClosed(lowerLeft.x() - expansion, upperRight.x() + expansion)
+                .mapToObj(x -> IntStream.rangeClosed(upperRight.y() - expansion, lowerLeft.y() + expansion)
+                        .mapToObj(y -> new TileZXY(zoom, x, y)))
+                .flatMap(stream -> stream);
+    }
+
+    /**
+     * Convert a tile to the bounds for that tile
+     * @param tile The tile to get the bounds for
+     * @return The bounds
+     */
+    public static Bounds tileToBounds(TileZXY tile) {
+        return new Bounds(yToLat(tile.y() + 1, tile.zoom()), xToLon(tile.x(), tile.zoom()),
+                yToLat(tile.y(), tile.zoom()), xToLon(tile.x() + 1, tile.zoom()));
+    }
+
+    /**
+     * Convert a x tile coordinate to a latitude
+     * @param x The x coordinate
+     * @param zoom The zoom level to use for the calculation
+     * @return The latitude for the x coordinate (upper-left of the tile)
+     */
+    public static double xToLon(int x, int zoom) {
+        return (x / Math.pow(2, zoom)) * 360 - 180;
+    }
+
+    /**
+     * Convert a y tile coordinate to a latitude
+     * @param y The y coordinate
+     * @param zoom The zoom level to use for the calculation
+     * @return The latitude for the y coordinate (upper-left of the tile)
+     */
+    public static double yToLat(int y, int zoom) {
+        double t = Math.PI - (2 * Math.PI * y) / Math.pow(2, zoom);
+        return 180 / Math.PI * Math.atan((Math.exp(t) - Math.exp(-t)) / 2);
+    }
+
+    /**
+     * Convert a lat, lon, and zoom to a tile coordiante
+     * @param lat The latitude
+     * @param lon The longitude
+     * @param zoom The zoom level
+     * @return The specified tile coordinates at the specified zoom
+     */
+    public static TileZXY latLonToTile(double lat, double lon, int zoom) {
+        int xCoord = (int) Math.floor(Math.pow(2, zoom) * (180 + lon) / 360);
+        int yCoord = (int) Math.floor(Math.pow(2, zoom) *
+                (1 - Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2);
+        return new TileZXY(zoom, xCoord, yCoord);
+    }
+
+    @Override
+    public String toString() {
+        return "TileZXY{" + zoom + "/" + x + "/" + y + "}";
+    }
+
+    @Override
+    public int hashCode() {
+        // We only care about comparing zoom, x, and y
+        return Integer.hashCode(this.zoom) + 31 * (Integer.hashCode(this.x) + 31 * Integer.hashCode(this.y));
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj instanceof TileZXY) {
+            TileZXY o = (TileZXY) obj;
+            return this.zoom == o.zoom && this.x == o.x && this.y == o.y;
+        }
+        return false;
+    }
+}
Index: /trunk/src/org/openstreetmap/josm/gui/MainMenu.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/gui/MainMenu.java	(revision 19175)
+++ /trunk/src/org/openstreetmap/josm/gui/MainMenu.java	(revision 19176)
@@ -104,4 +104,5 @@
 import org.openstreetmap.josm.actions.SplitWayAction;
 import org.openstreetmap.josm.actions.TaggingPresetSearchAction;
+import org.openstreetmap.josm.actions.TiledRenderToggleAction;
 import org.openstreetmap.josm.actions.UnGlueAction;
 import org.openstreetmap.josm.actions.UnJoinNodeWayAction;
@@ -249,4 +250,6 @@
     /** View / Wireframe View */
     public final WireframeToggleAction wireFrameToggleAction = new WireframeToggleAction();
+    /** View / Tiled Rendering */
+    public final TiledRenderToggleAction tiledRenderToggleAction = new TiledRenderToggleAction();
     /** View / Hatch area outside download */
     public final DrawBoundariesOfDownloadedDataAction drawBoundariesOfDownloadedDataAction = new DrawBoundariesOfDownloadedDataAction();
@@ -800,4 +803,10 @@
         wireframe.setAccelerator(wireFrameToggleAction.getShortcut().getKeyStroke());
         wireFrameToggleAction.addButtonModel(wireframe.getModel());
+        // -- tiled render toggle action -- not intended to be permanently an "Expert" mode option
+        final JCheckBoxMenuItem tiledRender = new JCheckBoxMenuItem(tiledRenderToggleAction);
+        viewMenu.add(tiledRender);
+        tiledRenderToggleAction.addButtonModel(tiledRender.getModel());
+        ExpertToggleAction.addVisibilitySwitcher(tiledRender);
+        // -- hatch toggle action
         final JCheckBoxMenuItem hatchAreaOutsideDownloadMenuItem = drawBoundariesOfDownloadedDataAction.getCheckbox();
         viewMenu.add(hatchAreaOutsideDownloadMenuItem);
Index: /trunk/src/org/openstreetmap/josm/gui/MapView.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/gui/MapView.java	(revision 19175)
+++ /trunk/src/org/openstreetmap/josm/gui/MapView.java	(revision 19176)
@@ -7,11 +7,14 @@
 import java.awt.BasicStroke;
 import java.awt.Color;
+import java.awt.Component;
 import java.awt.Dimension;
 import java.awt.Graphics;
 import java.awt.Graphics2D;
+import java.awt.GraphicsEnvironment;
 import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.Shape;
 import java.awt.Stroke;
+import java.awt.Transparency;
 import java.awt.event.ComponentAdapter;
 import java.awt.event.ComponentEvent;
@@ -331,4 +334,11 @@
     }
 
+    private static BufferedImage getAcceleratedImage(Component mv, int width, int height) {
+        if (GraphicsEnvironment.isHeadless()) {
+            return new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+        }
+        return mv.getGraphicsConfiguration().createCompatibleImage(width, height, Transparency.OPAQUE);
+    }
+
     // remebered geometry of the component
     private Dimension oldSize;
@@ -549,5 +559,5 @@
 
         if (null == offscreenBuffer || offscreenBuffer.getWidth() != width || offscreenBuffer.getHeight() != height) {
-            offscreenBuffer = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+            offscreenBuffer = getAcceleratedImage(this, width, height);
         }
 
@@ -555,5 +565,5 @@
             if (null == nonChangedLayersBuffer
                     || nonChangedLayersBuffer.getWidth() != width || nonChangedLayersBuffer.getHeight() != height) {
-                nonChangedLayersBuffer = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+                nonChangedLayersBuffer = getAcceleratedImage(this, width, height);
             }
             Graphics2D g2 = nonChangedLayersBuffer.createGraphics();
Index: /trunk/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java	(revision 19175)
+++ /trunk/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java	(revision 19176)
@@ -50,4 +50,6 @@
 import javax.swing.JScrollPane;
 
+import org.apache.commons.jcs3.access.CacheAccess;
+import org.openstreetmap.gui.jmapviewer.OsmMercator;
 import org.openstreetmap.josm.actions.AutoScaleAction;
 import org.openstreetmap.josm.actions.ExpertToggleAction;
@@ -57,6 +59,8 @@
 import org.openstreetmap.josm.data.Bounds;
 import org.openstreetmap.josm.data.Data;
+import org.openstreetmap.josm.data.IBounds;
 import org.openstreetmap.josm.data.ProjectionBounds;
 import org.openstreetmap.josm.data.UndoRedoHandler;
+import org.openstreetmap.josm.data.cache.JCSCacheManager;
 import org.openstreetmap.josm.data.conflict.Conflict;
 import org.openstreetmap.josm.data.conflict.ConflictCollection;
@@ -71,4 +75,5 @@
 import org.openstreetmap.josm.data.gpx.IGpxTrackSegment;
 import org.openstreetmap.josm.data.gpx.WayPoint;
+import org.openstreetmap.josm.data.osm.BBox;
 import org.openstreetmap.josm.data.osm.DataIntegrityProblemException;
 import org.openstreetmap.josm.data.osm.DataSelectionListener;
@@ -78,5 +83,8 @@
 import org.openstreetmap.josm.data.osm.DownloadPolicy;
 import org.openstreetmap.josm.data.osm.HighlightUpdateListener;
+import org.openstreetmap.josm.data.osm.INode;
 import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
 import org.openstreetmap.josm.data.osm.Node;
 import org.openstreetmap.josm.data.osm.OsmPrimitive;
@@ -92,5 +100,8 @@
 import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor;
 import org.openstreetmap.josm.data.osm.visitor.paint.AbstractMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.ImageCache;
 import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
+import org.openstreetmap.josm.data.osm.visitor.paint.StyledTiledMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.TileZXY;
 import org.openstreetmap.josm.data.osm.visitor.paint.relations.MultipolygonCache;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
@@ -105,4 +116,6 @@
 import org.openstreetmap.josm.gui.MapView;
 import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
+import org.openstreetmap.josm.gui.NavigatableComponent;
+import org.openstreetmap.josm.gui.PrimitiveHoverListener;
 import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils;
 import org.openstreetmap.josm.gui.datatransfer.data.OsmLayerTransferData;
@@ -145,5 +158,8 @@
  * @since 17
  */
-public class OsmDataLayer extends AbstractOsmDataLayer implements Listener, DataSelectionListener, HighlightUpdateListener {
+public class OsmDataLayer extends AbstractOsmDataLayer
+        implements Listener, DataSelectionListener, HighlightUpdateListener, PrimitiveHoverListener {
+    private static final int MAX_ZOOM = 30;
+    private static final int OVER_ZOOM = 2;
     private static final int HATCHED_SIZE = 15;
     // U+2205 EMPTY SET
@@ -156,4 +172,13 @@
     /** Flag used to know if the layer is being uploaded */
     private final AtomicBoolean isUploadInProgress = new AtomicBoolean(false);
+    /**
+     * A cache used for painting
+     */
+    private final CacheAccess<TileZXY, ImageCache> cache = JCSCacheManager.getCache("osmDataLayer:" + System.identityHashCode(this));
+    /** The map paint index that was painted (used to invalidate {@link #cache}) */
+    private int lastDataIdx;
+    /** The last zoom level (we invalidate all tiles when switching layers) */
+    private int lastZoom;
+    private boolean hoverListenerAdded;
 
     /**
@@ -498,11 +523,19 @@
      */
     @Override public void paint(final Graphics2D g, final MapView mv, Bounds box) {
+        if (!hoverListenerAdded) {
+            MainApplication.getMap().mapView.addPrimitiveHoverListener(this);
+            hoverListenerAdded = true;
+        }
         boolean active = mv.getLayerManager().getActiveLayer() == this;
         boolean inactive = !active && Config.getPref().getBoolean("draw.data.inactive_color", true);
         boolean virtual = !inactive && mv.isVirtualNodesEnabled();
-
+        paintHatch(g, mv, active);
+        paintData(g, mv, box, inactive, virtual);
+    }
+
+    private void paintHatch(final Graphics2D g, final MapView mv, boolean active) {
         // draw the hatched area for non-downloaded region. only draw if we're the active
         // and bounds are defined; don't draw for inactive layers or loaded GPX files etc
-        if (active && DrawingPreference.SOURCE_BOUNDS_PROP.get() && !data.getDataSources().isEmpty()) {
+        if (active && Boolean.TRUE.equals(DrawingPreference.SOURCE_BOUNDS_PROP.get()) && !data.getDataSources().isEmpty()) {
             // initialize area with current viewport
             Rectangle b = mv.getBounds();
@@ -537,9 +570,41 @@
             }
         }
-
+    }
+
+    private void paintData(final Graphics2D g, final MapView mv, Bounds box, boolean inactive, boolean virtual) {
+        // Used to invalidate cache
+        int zoom = getZoom(mv);
+        if (zoom != lastZoom) {
+            // We just mark the previous zoom as dirty before moving in.
+            // It means we don't have to traverse up/down z-levels marking tiles as dirty (this can get *very* expensive).
+            this.cache.getMatching("TileZXY\\{" + lastZoom + "/.*")
+                    .forEach((tile, imageCache) -> this.cache.put(tile, imageCache.becomeDirty()));
+        }
+        lastZoom = zoom;
         AbstractMapRenderer painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, inactive);
-        painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress()
-                || !PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get());
-        painter.render(data, virtual, box);
+        if (!(painter instanceof StyledTiledMapRenderer) || zoom - OVER_ZOOM > Config.getPref().getInt("mappaint.fast_render.zlevel", 16)) {
+            painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress()
+                    || !PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get());
+        } else {
+            StyledTiledMapRenderer renderer = (StyledTiledMapRenderer) painter;
+            renderer.setCache(box, this.cache, zoom, (tile) -> {
+                /* This causes "bouncing". I'm not certain why.
+                if (oldState.equalsInWindow(mv.getState())) { (oldstate = mv.getState())
+                    final Point upperLeft = mv.getPoint(tile);
+                    final Point lowerRight = mv.getPoint(new TileZXY(tile.zoom(), tile.x() + 1, tile.y() + 1));
+                    GuiHelper.runInEDT(() -> mv.repaint(0, upperLeft.x, upperLeft.y, lowerRight.x - upperLeft.x, lowerRight.y - upperLeft.y));
+                }
+                 */
+                // Invalidate doesn't trigger an instant repaint, but putting this off lets us batch the repaints needed for multiple tiles
+                MainApplication.worker.submit(this::invalidate);
+            });
+
+            if (this.data.getMappaintCacheIndex() != this.lastDataIdx) {
+                this.cache.clear();
+                this.lastDataIdx = this.data.getMappaintCacheIndex();
+                Logging.trace("OsmDataLayer {0} paint cache cleared", this.getName());
+            }
+        }
+        painter.render(this.data, virtual, box);
         MainApplication.getMap().conflictDialog.paintConflicts(g, mv);
     }
@@ -1148,4 +1213,8 @@
         removeClipboardDataFor(this);
         recentRelations.clear();
+        if (hoverListenerAdded) {
+            hoverListenerAdded = false;
+            MainApplication.getMap().mapView.removePrimitiveHoverListener(this);
+        }
     }
 
@@ -1166,4 +1235,5 @@
     @Override
     public void processDatasetEvent(AbstractDatasetChangedEvent event) {
+        resetTiles(event.getPrimitives());
         invalidate();
         setRequiresSaveToFile(true);
@@ -1173,5 +1243,115 @@
     @Override
     public void selectionChanged(SelectionChangeEvent event) {
+        Set<IPrimitive> primitives = new HashSet<>(event.getAdded());
+        primitives.addAll(event.getRemoved());
+        resetTiles(primitives);
         invalidate();
+    }
+
+    private void resetTiles(Collection<? extends IPrimitive> primitives) {
+        if (primitives.size() >= this.data.allNonDeletedCompletePrimitives().size() || primitives.size() > 100) {
+            dirtyAll();
+            return;
+        }
+        if (primitives.size() < 5) {
+            for (IPrimitive p : primitives) {
+                resetTiles(p);
+            }
+            return;
+        }
+        // Most of the time, a selection is going to be a big box.
+        // So we want to optimize for that case.
+        BBox box = null;
+        for (IPrimitive primitive : primitives) {
+            if (primitive == null || primitive.getDataSet() != this.getDataSet()) continue;
+            final Collection<? extends IPrimitive> referrers = primitive.getReferrers();
+            if (box == null) {
+                box = new BBox(primitive.getBBox());
+            } else {
+                box.addPrimitive(primitive, 0);
+            }
+            for (IPrimitive referrer : referrers) {
+                box.addPrimitive(referrer, 0);
+            }
+        }
+        if (box != null) {
+            resetBounds(box.getMinLat(), box.getMinLon(), box.getMaxLat(), box.getMaxLon());
+        }
+    }
+
+    private void resetTiles(IPrimitive p) {
+        if (p instanceof INode) {
+            resetBounds(getInvalidatedBBox((INode) p, null));
+        } else if (p instanceof IWay) {
+            IWay<?> way = (IWay<?>) p;
+            for (int i = 0; i < way.getNodesCount() - 1; i++) {
+                resetBounds(getInvalidatedBBox(way.getNode(i), way.getNode(i + 1)));
+            }
+        } else if (p instanceof IRelation<?>) {
+            for (IPrimitive member : ((IRelation<?>) p).getMemberPrimitivesList()) {
+                resetTiles(member);
+            }
+        } else {
+            throw new IllegalArgumentException("Unsupported primitive type: " + p.getClass().getName());
+        }
+    }
+
+    private BBox getInvalidatedBBox(INode first, INode second) {
+        final BBox bbox = new BBox(first);
+        if (second != null) {
+            bbox.add(second);
+        }
+        return bbox;
+    }
+
+    private void resetBounds(IBounds bbox) {
+        resetBounds(bbox.getMinLat(), bbox.getMinLon(), bbox.getMaxLat(), bbox.getMaxLon());
+    }
+
+    private void resetBounds(double minLat, double minLon, double maxLat, double maxLon) {
+        // Get the current zoom. Hopefully we aren't painting with a different navigatable component
+        final int currentZoom = lastZoom;
+        final AtomicInteger counter = new AtomicInteger();
+        TileZXY.boundsToTiles(minLat, minLon, maxLat, maxLon, currentZoom, 1).limit(100).forEach(tile -> {
+            final ImageCache imageCache = this.cache.get(tile);
+            if (imageCache != null && !imageCache.isDirty()) {
+                this.cache.put(tile, imageCache.becomeDirty());
+            }
+            counter.incrementAndGet();
+        });
+        if (counter.get() > 100) {
+            dirtyAll();
+        }
+    }
+
+    private void dirtyAll() {
+        this.cache.getMatching(".*").forEach((key, value) -> {
+            this.cache.remove(key);
+            this.cache.put(key, value.becomeDirty());
+        });
+    }
+
+    /**
+     * Get the zoom for a {@link NavigatableComponent}
+     * @param navigatableComponent The component to get the zoom from
+     * @return The zoom for the navigatable component
+     */
+    private static int getZoom(NavigatableComponent navigatableComponent) {
+        final double scale = navigatableComponent.getScale();
+        // We might have to fall back to the old method if user is reprojecting
+        // 256 is the "target" size, (TODO check HiDPI!)
+        final int targetSize = Config.getPref().getInt("mappaint.fast_render.tile_size", 256);
+        final double topResolution = 2 * Math.PI * OsmMercator.EARTH_RADIUS / targetSize;
+        int zoom;
+        for (zoom = 0; zoom < MAX_ZOOM; zoom++) { // Use something like imagery.{generic|tms}.max_zoom_lvl (20 is a bit too low for our needs)
+            if (scale > topResolution / Math.pow(2, zoom)) {
+                zoom = zoom > 0 ? zoom - 1 : zoom;
+                break;
+            }
+        }
+        // We paint at a few levels higher, note that the tiles are appropriately sized (if 256 is the "target" size, the tiles should be
+        // 64px square).
+        zoom += OVER_ZOOM;
+        return zoom;
     }
 
@@ -1310,4 +1490,14 @@
 
     @Override
+    public void primitiveHovered(PrimitiveHoverEvent e) {
+        List<IPrimitive> primitives = new ArrayList<>(2);
+        primitives.add(e.getHoveredPrimitive());
+        primitives.add(e.getPreviousPrimitive());
+        primitives.removeIf(Objects::isNull);
+        resetTiles(primitives);
+        this.invalidate();
+    }
+
+    @Override
     public void setName(String name) {
         if (data != null) {
