diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
index eb4ea72c4d..3d44b4ad17 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
@@ -1,6 +1,7 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.text.NumberFormat;
 import java.util.ArrayList;
@@ -26,10 +27,11 @@ public class Feature {
     private static final byte GEOMETRY_FIELD = 4;
     /**
      * The number format instance to use (using a static instance gets rid of quite o few allocations)
-     * Doing this reduced the allocations of {@link #parseTagValue(String, Layer, Number)} from 22.79% of parent to
+     * Doing this reduced the allocations of {@link #parseTagValue(String, Layer, Number, List)} from 22.79% of parent to
      * 12.2% of parent.
      */
     private static final NumberFormat NUMBER_FORMAT = NumberFormat.getNumberInstance(Locale.ROOT);
+    private static final String[] EMPTY_STRING_ARRAY = new String[0];
     /**
      * The geometry of the feature. Required.
      */
@@ -47,7 +49,7 @@ public class Feature {
     /**
      * The tags of the feature. Optional.
      */
-    private TagMap tags;
+    private final TagMap tags;
     private Geometry geometryObject;
 
     /**
@@ -61,21 +63,29 @@ public class Feature {
         long tId = 0;
         GeometryTypes geometryTypeTemp = GeometryTypes.UNKNOWN;
         String key = null;
+        // Use a list where we can grow capacity easily (TagMap will do an array copy every time a tag is added)
+        // This lets us avoid most array copies (i.e., this should only happen if some software decided it would be
+        // a good idea to have multiple tag fields).
+        // By avoiding array copies in TagMap, Feature#init goes from 339 MB to 188 MB.
+        ArrayList<String> tagList = null;
         try (ProtobufParser parser = new ProtobufParser(record.getBytes())) {
+            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(4);
             while (parser.hasNext()) {
-                try (ProtobufRecord next = new ProtobufRecord(parser)) {
+                try (ProtobufRecord next = new ProtobufRecord(byteArrayOutputStream, parser)) {
                     if (next.getField() == TAG_FIELD) {
-                        if (tags == null) {
-                            tags = new TagMap();
-                        }
                         // This is packed in v1 and v2
-                        ProtobufPacked packed = new ProtobufPacked(next.getBytes());
+                        ProtobufPacked packed = new ProtobufPacked(byteArrayOutputStream, next.getBytes());
+                        if (tagList == null) {
+                            tagList = new ArrayList<>(packed.getArray().length);
+                        } else {
+                            tagList.ensureCapacity(tagList.size() + packed.getArray().length);
+                        }
                         for (Number number : packed.getArray()) {
-                            key = parseTagValue(key, layer, number);
+                            key = parseTagValue(key, layer, number, tagList);
                         }
                     } else if (next.getField() == GEOMETRY_FIELD) {
                         // This is packed in v1 and v2
-                        ProtobufPacked packed = new ProtobufPacked(next.getBytes());
+                        ProtobufPacked packed = new ProtobufPacked(byteArrayOutputStream, next.getBytes());
                         CommandInteger currentCommand = null;
                         for (Number number : packed.getArray()) {
                             if (currentCommand != null && currentCommand.hasAllExpectedParameters()) {
@@ -90,7 +100,8 @@ public class Feature {
                         }
                         // TODO fallback to non-packed
                     } else if (next.getField() == GEOMETRY_TYPE_FIELD) {
-                        geometryTypeTemp = GeometryTypes.values()[next.asUnsignedVarInt().intValue()];
+                        // by using getAllValues, we avoid 12.4 MB allocations
+                        geometryTypeTemp = GeometryTypes.getAllValues()[next.asUnsignedVarInt().intValue()];
                     } else if (next.getField() == ID_FIELD) {
                         tId = next.asUnsignedVarInt().longValue();
                     }
@@ -100,6 +111,11 @@ public class Feature {
         this.id = tId;
         this.geometryType = geometryTypeTemp;
         record.close();
+        if (tagList != null && !tagList.isEmpty()) {
+            this.tags = new TagMap(tagList.toArray(EMPTY_STRING_ARRAY));
+        } else {
+            this.tags = null;
+        }
     }
 
     /**
@@ -108,12 +124,14 @@ public class Feature {
      * @param key    The current key (or {@code null}, if {@code null}, the returned value will be the new key)
      * @param layer  The layer with key/value information
      * @param number The number to get the value from
+     * @param tagList The list to add the new value to
      * @return The new key (if {@code null}, then a value was parsed and added to tags)
      */
-    private String parseTagValue(String key, Layer layer, Number number) {
+    private String parseTagValue(String key, Layer layer, Number number, List<String> tagList) {
         if (key == null) {
             key = layer.getKey(number.intValue());
         } else {
+            tagList.add(key);
             Object value = layer.getValue(number.intValue());
             if (value instanceof Double || value instanceof Float) {
                 // reset grouping if the instance is a singleton
@@ -121,12 +139,12 @@ public class Feature {
                 final boolean grouping = NUMBER_FORMAT.isGroupingUsed();
                 try {
                     NUMBER_FORMAT.setGroupingUsed(false);
-                    this.tags.put(key, NUMBER_FORMAT.format(value));
+                    tagList.add(Utils.intern(NUMBER_FORMAT.format(value)));
                 } finally {
                     NUMBER_FORMAT.setGroupingUsed(grouping);
                 }
             } else {
-                this.tags.put(key, Utils.intern(value.toString()));
+                tagList.add(Utils.intern(value.toString()));
             }
             key = null;
         }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
index 87aff165f7..0e5976c100 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
@@ -18,7 +18,7 @@ import java.util.List;
  * @since 17862
  */
 public class Geometry {
-    final Collection<Shape> shapes = new ArrayList<>();
+    final Collection<Shape> shapes;
 
     /**
      * Create a {@link Geometry} for a {@link Feature}
@@ -28,73 +28,98 @@ public class Geometry {
      */
     public Geometry(GeometryTypes geometryType, List<CommandInteger> commands) {
         if (geometryType == GeometryTypes.POINT) {
-            for (CommandInteger command : commands) {
-                final short[] operations = command.getOperations();
-                // Each MoveTo command is a new point
-                if (command.getType() == Command.MoveTo && operations.length % 2 == 0 && operations.length > 0) {
-                    for (int i = 0; i < operations.length / 2; i++) {
-                        // Just using Ellipse2D since it extends Shape
-                        shapes.add(new Ellipse2D.Float(operations[2 * i], operations[2 * i + 1], 0, 0));
-                    }
-                } else {
-                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+            // This gets rid of most of the expensive array copies from ArrayList#grow
+            shapes = new ArrayList<>(commands.size());
+            initializePoints(geometryType, commands);
+        } else if (geometryType == GeometryTypes.LINESTRING || geometryType == GeometryTypes.POLYGON) {
+            // This gets rid of most of the expensive array copies from ArrayList#grow
+            shapes = new ArrayList<>(1);
+            initializeWayGeometry(geometryType, commands);
+        } else {
+            shapes = Collections.emptyList();
+        }
+    }
+
+    /**
+     * Initialize point geometry
+     * @param geometryType The geometry type (used for logging)
+     * @param commands The commands to use to create the geometry
+     */
+    private void initializePoints(GeometryTypes geometryType, List<CommandInteger> commands) {
+        for (CommandInteger command : commands) {
+            final short[] operations = command.getOperations();
+            // Each MoveTo command is a new point
+            if (command.getType() == Command.MoveTo && operations.length % 2 == 0 && operations.length > 0) {
+                for (int i = 0; i < operations.length / 2; i++) {
+                    // Just using Ellipse2D since it extends Shape
+                    shapes.add(new Ellipse2D.Float(operations[2 * i], operations[2 * i + 1], 0, 0));
                 }
+            } else {
+                throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
             }
-        } else if (geometryType == GeometryTypes.LINESTRING || geometryType == GeometryTypes.POLYGON) {
-            Path2D.Float line = null;
-            Area area = null;
-            // MVT uses delta encoding. Each feature starts at (0, 0).
-            int x = 0;
-            int y = 0;
-            // Area is used to determine the inner/outer of a polygon
-            final int maxArraySize = commands.stream().filter(command -> command.getType() != Command.ClosePath)
-                    .mapToInt(command -> command.getOperations().length).sum();
-            final List<Integer> xArray = new ArrayList<>(maxArraySize);
-            final List<Integer> yArray = new ArrayList<>(maxArraySize);
-            for (CommandInteger command : commands) {
-                final short[] operations = command.getOperations();
-                // Technically, there is no reason why there can be multiple MoveTo operations in one command, but that is undefined behavior
-                if (command.getType() == Command.MoveTo && operations.length == 2) {
-                    x += operations[0];
-                    y += operations[1];
-                    line = new Path2D.Float();
-                    line.moveTo(x, y);
+        }
+    }
+
+    /**
+     * Initialize way geometry
+     * @param geometryType The geometry type
+     * @param commands The commands to use to create the geometry
+     */
+    private void initializeWayGeometry(GeometryTypes geometryType, List<CommandInteger> commands) {
+        Path2D.Float line = null;
+        Area area = null;
+        // MVT uses delta encoding. Each feature starts at (0, 0).
+        int x = 0;
+        int y = 0;
+        // Area is used to determine the inner/outer of a polygon
+        final int maxArraySize = commands.stream().filter(command -> command.getType() != Command.ClosePath)
+                .mapToInt(command -> command.getOperations().length).sum();
+        final List<Integer> xArray = new ArrayList<>(maxArraySize);
+        final List<Integer> yArray = new ArrayList<>(maxArraySize);
+        for (CommandInteger command : commands) {
+            final short[] operations = command.getOperations();
+            // Technically, there is no reason why there can be multiple MoveTo operations in one command, but that is undefined behavior
+            if (command.getType() == Command.MoveTo && operations.length == 2) {
+                x += operations[0];
+                y += operations[1];
+                // Avoid fairly expensive Arrays.copyOf calls
+                line = new Path2D.Float(Path2D.WIND_NON_ZERO, commands.size());
+                line.moveTo(x, y);
+                xArray.add(x);
+                yArray.add(y);
+                shapes.add(line);
+            } else if (command.getType() == Command.LineTo && operations.length % 2 == 0 && line != null) {
+                for (int i = 0; i < operations.length / 2; i++) {
+                    x += operations[2 * i];
+                    y += operations[2 * i + 1];
                     xArray.add(x);
                     yArray.add(y);
-                    shapes.add(line);
-                } else if (command.getType() == Command.LineTo && operations.length % 2 == 0 && line != null) {
-                    for (int i = 0; i < operations.length / 2; i++) {
-                        x += operations[2 * i];
-                        y += operations[2 * i + 1];
-                        xArray.add(x);
-                        yArray.add(y);
-                        line.lineTo(x, y);
-                    }
+                    line.lineTo(x, y);
+                }
                 // ClosePath should only be used with Polygon geometry
-                } else if (geometryType == GeometryTypes.POLYGON && command.getType() == Command.ClosePath && line != null) {
-                    shapes.remove(line);
-                    // new Area() closes the line if it isn't already closed
-                    if (area == null) {
-                        area = new Area();
-                        shapes.add(area);
-                    }
+            } else if (geometryType == GeometryTypes.POLYGON && command.getType() == Command.ClosePath && line != null) {
+                shapes.remove(line);
+                // new Area() closes the line if it isn't already closed
+                if (area == null) {
+                    area = new Area();
+                    shapes.add(area);
+                }
 
-                    final double areaAreaSq = calculateSurveyorsArea(xArray.stream().mapToInt(i -> i).toArray(),
-                            yArray.stream().mapToInt(i -> i).toArray());
-                    Area nArea = new Area(line);
-                    // SonarLint thinks that this is never > 0. It can be.
-                    if (areaAreaSq > 0) {
-                        area.add(nArea);
-                    } else if (areaAreaSq < 0) {
-                        area.exclusiveOr(nArea);
-                    } else {
-                        throw new IllegalArgumentException(tr("{0} cannot have zero area", geometryType));
-                    }
-                    xArray.clear();
-                    yArray.clear();
+                final double areaAreaSq = calculateSurveyorsArea(xArray.stream().mapToInt(i -> i).toArray(),
+                        yArray.stream().mapToInt(i -> i).toArray());
+                Area nArea = new Area(line);
+                // SonarLint thinks that this is never > 0. It can be.
+                if (areaAreaSq > 0) {
+                    area.add(nArea);
+                } else if (areaAreaSq < 0) {
+                    area.exclusiveOr(nArea);
                 } else {
-                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                    throw new IllegalArgumentException(tr("{0} cannot have zero area", geometryType));
                 }
+                xArray.clear();
+                yArray.clear();
+            } else {
+                throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
             }
         }
     }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
index 2341207bc5..4694ddde1c 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
@@ -18,6 +18,7 @@ public enum GeometryTypes {
      * and one {@link Command#ClosePath} command. See {@link Ring}s. */
     POLYGON;
 
+    private static final GeometryTypes[] CACHED_VALUES = values();
     /**
      * Rings used by {@link GeometryTypes#POLYGON}
      * @author Taylor Smock
@@ -28,4 +29,13 @@ public enum GeometryTypes {
         /** A ring that goes in the anti-clockwise direction */
         InteriorRing
     }
+
+    /**
+     * A replacement for {@link #values()} which can be used when there are no changes to the underlying array.
+     * This is useful for avoiding unnecessary allocations.
+     * @return A cached array from {@link #values()}. Do not modify.
+     */
+    static GeometryTypes[] getAllValues() {
+        return CACHED_VALUES;
+    }
 }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
index ce5680c531..1eb0e5a423 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -1,13 +1,15 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
 import static org.openstreetmap.josm.tools.I18n.tr;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -17,7 +19,6 @@ import java.util.stream.Collectors;
 import org.openstreetmap.josm.data.protobuf.ProtobufParser;
 import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
 import org.openstreetmap.josm.tools.Destroyable;
-import org.openstreetmap.josm.tools.Logging;
 
 /**
  * A Mapbox Vector Tile Layer
@@ -99,50 +100,62 @@ public final class Layer implements Destroyable {
      */
     public Layer(Collection<ProtobufRecord> records) throws IOException {
         // Do the unique required fields first
-        Map<Integer, List<ProtobufRecord>> sorted = records.stream().collect(Collectors.groupingBy(ProtobufRecord::getField));
-        this.version = sorted.getOrDefault((int) VERSION_FIELD, Collections.emptyList()).parallelStream()
-          .map(ProtobufRecord::asUnsignedVarInt).map(Number::byteValue).findFirst().orElse(DEFAULT_VERSION);
-        // Per spec, we cannot continue past this until we have checked the version number
-        if (this.version != 1 && this.version != 2) {
-            throw new IllegalArgumentException(tr("We do not understand version {0} of the vector tile specification", this.version));
-        }
-        this.name = sorted.getOrDefault((int) NAME_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asString).findFirst()
-                .orElseThrow(() -> new IllegalArgumentException(tr("Vector tile layers must have a layer name")));
-        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asUnsignedVarInt)
-                .map(Number::intValue).findAny().orElse(DEFAULT_EXTENT);
-
-        sorted.getOrDefault((int) KEY_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asString)
-                .forEachOrdered(this.keyList::add);
-        sorted.getOrDefault((int) VALUE_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::getBytes)
-                .map(ProtobufParser::new).map(parser1 -> {
-                    try {
-                        return new ProtobufRecord(parser1);
-                    } catch (IOException e) {
-                        Logging.error(e);
-                        return null;
-                    }
-                })
-                .filter(Objects::nonNull)
-                .map(value -> ValueFields.MAPPERS.parallelStream()
-                        .filter(v -> v.getField() == value.getField())
-                        .map(v -> v.convertValue(value)).findFirst()
-                        .orElseThrow(() -> new IllegalArgumentException(tr("Unknown field in vector tile layer value ({0})", value.getField()))))
-                .forEachOrdered(this.valueList::add);
-        Collection<IOException> exceptions = new HashSet<>(0);
-        this.featureCollection = sorted.getOrDefault((int) FEATURE_FIELD, Collections.emptyList()).parallelStream().map(feature -> {
-            try {
-                return new Feature(this, feature);
-            } catch (IOException e) {
-                exceptions.add(e);
+        Map<Integer, List<ProtobufRecord>> sorted = new HashMap<>(records.size());
+        byte tVersion = DEFAULT_VERSION;
+        String tName = null;
+        int tExtent = DEFAULT_EXTENT;
+        final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(4);
+        for (ProtobufRecord protobufRecord : records) {
+            if (protobufRecord.getField() == VERSION_FIELD) {
+                tVersion = protobufRecord.asUnsignedVarInt().byteValue();
+                // Per spec, we cannot continue past this until we have checked the version number
+                if (tVersion != 1 && tVersion != 2) {
+                    throw new IllegalArgumentException(tr("We do not understand version {0} of the vector tile specification", tVersion));
+                }
+            } else if (protobufRecord.getField() == NAME_FIELD) {
+                tName = protobufRecord.asString();
+            } else if (protobufRecord.getField() == EXTENT_FIELD) {
+                tExtent = protobufRecord.asUnsignedVarInt().intValue();
+            } else if (protobufRecord.getField() == KEY_FIELD) {
+                this.keyList.add(protobufRecord.asString());
+            } else if (protobufRecord.getField() == VALUE_FIELD) {
+                parseValueRecord(byteArrayOutputStream, protobufRecord);
+            } else {
+                sorted.computeIfAbsent(protobufRecord.getField(), i -> new ArrayList<>(records.size())).add(protobufRecord);
             }
-            return null;
-        }).collect(Collectors.toList());
-        if (!exceptions.isEmpty()) {
-            throw exceptions.iterator().next();
+        }
+        this.version = tVersion;
+        if (tName == null) {
+            throw new IllegalArgumentException(tr("Vector tile layers must have a layer name"));
+        }
+        this.name = tName;
+        this.extent = tExtent;
+
+        this.featureCollection = new ArrayList<>(sorted.getOrDefault((int) FEATURE_FIELD, Collections.emptyList()).size());
+        for (ProtobufRecord protobufRecord : sorted.getOrDefault((int) FEATURE_FIELD, Collections.emptyList())) {
+            this.featureCollection.add(new Feature(this, protobufRecord));
         }
         // Cleanup bytes (for memory)
-        for (ProtobufRecord record : records) {
-            record.close();
+        for (ProtobufRecord protobufRecord : records) {
+            protobufRecord.close();
+        }
+    }
+
+    private void parseValueRecord(ByteArrayOutputStream byteArrayOutputStream, ProtobufRecord protobufRecord)
+            throws IOException {
+        try (ProtobufParser parser = new ProtobufParser(protobufRecord.getBytes())) {
+            ProtobufRecord protobufRecord2 = new ProtobufRecord(byteArrayOutputStream, parser);
+            int field = protobufRecord2.getField();
+            int valueListSize = this.valueList.size();
+            for (Layer.ValueFields<?> mapper : ValueFields.MAPPERS) {
+                if (mapper.getField() == field) {
+                    this.valueList.add(mapper.convertValue(protobufRecord2));
+                    break;
+                }
+            }
+            if (valueListSize == this.valueList.size()) {
+                throw new IllegalArgumentException(tr("Unknown field in vector tile layer value ({0})", field));
+            }
         }
     }
 
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
index 5aa200d603..148e37a30d 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
@@ -8,7 +8,6 @@ import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
-import java.util.stream.Collectors;
 
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
@@ -53,12 +52,11 @@ public class MVTTile extends Tile implements VectorTile, IQuadBucketType {
             this.initLoading();
             ProtobufParser parser = new ProtobufParser(inputStream);
             Collection<ProtobufRecord> protobufRecords = parser.allRecords();
-            this.layers = new HashSet<>();
-            this.layers = protobufRecords.stream().map(protoBufRecord -> {
-                Layer mvtLayer = null;
+            this.layers = new HashSet<>(protobufRecords.size());
+            for (ProtobufRecord protoBufRecord : protobufRecords) {
                 if (protoBufRecord.getField() == Layer.LAYER_FIELD) {
                     try (ProtobufParser tParser = new ProtobufParser(protoBufRecord.getBytes())) {
-                        mvtLayer = new Layer(tParser.allRecords());
+                        this.layers.add(new Layer(tParser.allRecords()));
                     } catch (IOException e) {
                         Logging.error(e);
                     } finally {
@@ -66,8 +64,9 @@ public class MVTTile extends Tile implements VectorTile, IQuadBucketType {
                         protoBufRecord.close();
                     }
                 }
-                return mvtLayer;
-            }).collect(Collectors.toCollection(HashSet::new));
+            }
+            this.layers = new HashSet<>(this.layers);
+
             this.extent = layers.stream().filter(Objects::nonNull).mapToInt(Layer::getExtent).max().orElse(Layer.DEFAULT_EXTENT);
             if (this.getData() != null) {
                 this.finishLoading();
diff --git a/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java b/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
index b4b1e1e339..09556dd3c3 100644
--- a/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
+++ b/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
@@ -5,6 +5,7 @@ import static org.openstreetmap.josm.tools.I18n.tr;
 
 import java.text.MessageFormat;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -611,6 +612,38 @@ public abstract class AbstractPrimitive implements IPrimitive, IFilterablePrimit
         }
     }
 
+    @Override
+    public void putAll(Map<String, String> tags) {
+        if (tags == null || tags.isEmpty()) {
+            return;
+        }
+        // Defensive copy of keys
+        String[] newKeys = keys;
+        Map<String, String> originalKeys = getKeys();
+        List<Map.Entry<String, String>> tagsToAdd = new ArrayList<>(tags.size());
+        for (Map.Entry<String, String> tag : tags.entrySet()) {
+            if (!Utils.isBlank(tag.getKey())) {
+                int keyIndex = indexOfKey(newKeys, tag.getKey());
+                // Realistically, we will not hit the newKeys == null branch. If it is null, keyIndex is always < 1
+                if (keyIndex < 0 || newKeys == null) {
+                    tagsToAdd.add(tag);
+                } else {
+                    newKeys[keyIndex + 1] = tag.getValue();
+                }
+            }
+        }
+        if (!tagsToAdd.isEmpty()) {
+            int index = newKeys != null ? newKeys.length : 0;
+            newKeys = newKeys != null ? Arrays.copyOf(newKeys, newKeys.length + 2 * tagsToAdd.size()) : new String[2 * tagsToAdd.size()];
+            for (Map.Entry<String, String> tag : tagsToAdd) {
+                newKeys[index++] = tag.getKey();
+                newKeys[index++] = tag.getValue();
+            }
+            keys = newKeys;
+        }
+        keysChangedImpl(originalKeys);
+    }
+
     /**
      * Scans a key/value array for a given key.
      * @param keys The key array. It is not modified. It may be null to indicate an empty array.
diff --git a/src/org/openstreetmap/josm/data/osm/Tagged.java b/src/org/openstreetmap/josm/data/osm/Tagged.java
index 067fc804c4..faadcd0bd9 100644
--- a/src/org/openstreetmap/josm/data/osm/Tagged.java
+++ b/src/org/openstreetmap/josm/data/osm/Tagged.java
@@ -68,6 +68,18 @@ public interface Tagged {
         put(tag.getKey(), tag.getValue());
     }
 
+    /**
+     * Add all key/value pairs. This <i>may</i> be more performant than {@link #put}, depending upon the implementation.
+     * By default, this calls {@link #put} for each map entry.
+     * @param tags The tag map to add
+     * @since xxx
+     */
+    default void putAll(Map<String, String> tags) {
+        for (Map.Entry<String, String> entry : tags.entrySet()) {
+            put(entry.getKey(), entry.getValue());
+        }
+    }
+
     /**
      * Replies the value of the given key; null, if there is no value for this key
      *
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
index c4c331c2cc..1b494514f0 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
@@ -12,6 +12,7 @@ import java.util.List;
  * @since 17862
  */
 public class ProtobufPacked {
+    private static final Number[] NO_NUMBERS = new Number[0];
     private final byte[] bytes;
     private final Number[] numbers;
     private int location;
@@ -19,23 +20,23 @@ public class ProtobufPacked {
     /**
      * Create a new ProtobufPacked object
      *
+     * @param byteArrayOutputStream A reusable ByteArrayOutputStream (helps to reduce memory allocations)
      * @param bytes The packed bytes
      */
-    public ProtobufPacked(byte[] bytes) {
+    public ProtobufPacked(ByteArrayOutputStream byteArrayOutputStream, byte[] bytes) {
         this.location = 0;
         this.bytes = bytes;
-        List<Number> numbersT = new ArrayList<>();
+
+        // By creating a list of size bytes.length, we avoid 36 MB of allocations from list growth. This initialization
+        // only adds 3.7 MB to the ArrayList#init calls. Note that the real-world test case (Mapillary vector tiles)
+        // primarily created Shorts.
+        List<Number> numbersT = new ArrayList<>(bytes.length);
         // By reusing a ByteArrayOutputStream, we can reduce allocations in nextVarInt from 230 MB to 74 MB.
-        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(4);
         while (this.location < bytes.length) {
             numbersT.add(ProtobufParser.convertByteArray(this.nextVarInt(byteArrayOutputStream), ProtobufParser.VAR_INT_BYTE_SIZE));
-            byteArrayOutputStream.reset();
         }
 
-        this.numbers = new Number[numbersT.size()];
-        for (int i = 0; i < numbersT.size(); i++) {
-            this.numbers[i] = numbersT.get(i);
-        }
+        this.numbers = numbersT.toArray(NO_NUMBERS);
     }
 
     /**
@@ -50,7 +51,8 @@ public class ProtobufPacked {
     private byte[] nextVarInt(final ByteArrayOutputStream byteArrayOutputStream) {
         // In a real world test, the largest List<Byte> seen had 3 elements. Use 4 to avoid most new array allocations.
         // Memory allocations went from 368 MB to 280 MB by using an initial array allocation. When using a
-        // ByteArrayOutputStream, it went down to 230 MB.
+        // ByteArrayOutputStream, it went down to 230 MB. By further reusing the ByteArrayOutputStream between method
+        // calls, it went down further to 73 MB.
         while ((this.bytes[this.location] & ProtobufParser.MOST_SIGNIFICANT_BYTE)
           == ProtobufParser.MOST_SIGNIFICANT_BYTE) {
             // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
@@ -58,6 +60,10 @@ public class ProtobufPacked {
         }
         // The last byte doesn't drop the most significant bit
         byteArrayOutputStream.write(this.bytes[this.location++]);
-        return byteArrayOutputStream.toByteArray();
+        try {
+            return byteArrayOutputStream.toByteArray();
+        } finally {
+            byteArrayOutputStream.reset();
+        }
     }
 }
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
index dd2553223f..60d4fb29aa 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
@@ -121,8 +121,9 @@ public class ProtobufParser implements AutoCloseable {
      */
     public Collection<ProtobufRecord> allRecords() throws IOException {
         Collection<ProtobufRecord> records = new ArrayList<>();
+        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(4);
         while (this.hasNext()) {
-            records.add(new ProtobufRecord(this));
+            records.add(new ProtobufRecord(byteArrayOutputStream, this));
         }
         return records;
     }
@@ -196,23 +197,26 @@ public class ProtobufParser implements AutoCloseable {
     /**
      * Get the next delimited message ({@link WireType#LENGTH_DELIMITED})
      *
+     * @param byteArrayOutputStream A reusable stream to write bytes to. This can significantly reduce the allocations
+     *                              (150 MB to 95 MB in a test area).
      * @return The next length delimited message
      * @throws IOException - if an IO error occurs
      */
-    public byte[] nextLengthDelimited() throws IOException {
-        int length = convertByteArray(this.nextVarInt(), VAR_INT_BYTE_SIZE).intValue();
+    public byte[] nextLengthDelimited(ByteArrayOutputStream byteArrayOutputStream) throws IOException {
+        int length = convertByteArray(this.nextVarInt(byteArrayOutputStream), VAR_INT_BYTE_SIZE).intValue();
         return readNextBytes(length);
     }
 
     /**
      * Get the next var int ({@code WireType#VARINT})
      *
+     * @param byteArrayOutputStream A reusable stream to write bytes to. This can significantly reduce the allocations
+     *                              (150 MB to 95 MB in a test area).
      * @return The next var int ({@code int32}, {@code int64}, {@code uint32}, {@code uint64}, {@code bool}, {@code enum})
      * @throws IOException - if an IO error occurs
      */
-    public byte[] nextVarInt() throws IOException {
+    public byte[] nextVarInt(ByteArrayOutputStream byteArrayOutputStream) throws IOException {
         // Using this reduces the allocations from 150 MB to 95 MB.
-        final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(4);
         int currentByte = this.nextByte();
         while ((byte) (currentByte & MOST_SIGNIFICANT_BYTE) == MOST_SIGNIFICANT_BYTE && currentByte > 0) {
             // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
@@ -221,7 +225,11 @@ public class ProtobufParser implements AutoCloseable {
         }
         // The last byte doesn't drop the most significant bit
         byteArrayOutputStream.write(currentByte);
-        return byteArrayOutputStream.toByteArray();
+        try {
+            return byteArrayOutputStream.toByteArray();
+        } finally {
+            byteArrayOutputStream.reset();
+        }
     }
 
     /**
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
index 6c9784215f..1a679100ea 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
@@ -1,6 +1,7 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.protobuf;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 
@@ -21,11 +22,12 @@ public class ProtobufRecord implements AutoCloseable {
     /**
      * Create a new Protobuf record
      *
+     * @param byteArrayOutputStream A reusable ByteArrayOutputStream to avoid unnecessary allocations
      * @param parser The parser to use to create the record
      * @throws IOException - if an IO error occurs
      */
-    public ProtobufRecord(ProtobufParser parser) throws IOException {
-        Number number = ProtobufParser.convertByteArray(parser.nextVarInt(), ProtobufParser.VAR_INT_BYTE_SIZE);
+    public ProtobufRecord(ByteArrayOutputStream byteArrayOutputStream, ProtobufParser parser) throws IOException {
+        Number number = ProtobufParser.convertByteArray(parser.nextVarInt(byteArrayOutputStream), ProtobufParser.VAR_INT_BYTE_SIZE);
         // I don't foresee having field numbers > {@code Integer#MAX_VALUE >> 3}
         this.field = (int) number.longValue() >> 3;
         // 7 is 111 (so last three bits)
@@ -42,13 +44,13 @@ public class ProtobufRecord implements AutoCloseable {
         this.type = tType;
 
         if (this.type == WireType.VARINT) {
-            this.bytes = parser.nextVarInt();
+            this.bytes = parser.nextVarInt(byteArrayOutputStream);
         } else if (this.type == WireType.SIXTY_FOUR_BIT) {
             this.bytes = parser.nextFixed64();
         } else if (this.type == WireType.THIRTY_TWO_BIT) {
             this.bytes = parser.nextFixed32();
         } else if (this.type == WireType.LENGTH_DELIMITED) {
-            this.bytes = parser.nextLengthDelimited();
+            this.bytes = parser.nextLengthDelimited(byteArrayOutputStream);
         } else {
             this.bytes = EMPTY_BYTES;
         }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index d339627e9b..44584737d4 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -12,13 +12,16 @@ import java.awt.geom.PathIterator;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 
-import org.openstreetmap.gui.jmapviewer.Coordinate;
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
 import org.openstreetmap.josm.data.IQuadBucketType;
+import org.openstreetmap.josm.data.coor.ILatLon;
+import org.openstreetmap.josm.data.coor.LatLon;
 import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Feature;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
@@ -35,6 +38,7 @@ import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Geometry;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.Utils;
 
 /**
  * A data store for Vector Data sets
@@ -156,7 +160,7 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
     }
 
     private synchronized <T extends Tile & VectorTile> VectorNode pointToNode(T tile, Layer layer,
-      Collection<VectorPrimitive> featureObjects, int x, int y) {
+      Collection<VectorPrimitive> featureObjects, int x, int y, final Map<ILatLon, VectorNode> nodeMap) {
         final BBox tileBbox;
         if (tile instanceof IQuadBucketType) {
             tileBbox = ((IQuadBucketType) tile).getBBox();
@@ -168,12 +172,15 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
             tileBbox = new BBox(upperLeft.getLon(), upperLeft.getLat(), lowerRight.getLon(), lowerRight.getLat());
         }
         final int layerExtent = layer.getExtent();
-        final ICoordinate coords = new Coordinate(
+        final LatLon coords = new LatLon(
                 tileBbox.getMaxLat() - (tileBbox.getMaxLat() - tileBbox.getMinLat()) * y / layerExtent,
                 tileBbox.getMinLon() + (tileBbox.getMaxLon() - tileBbox.getMinLon()) * x / layerExtent
         );
+        if (nodeMap.containsKey(coords)) {
+            return nodeMap.get(coords);
+        }
         final Collection<VectorNode> nodes = this.store
-          .searchNodes(new BBox(coords.getLon(), coords.getLat(), VectorDataSet.DUPE_NODE_DISTANCE));
+          .searchNodes(new BBox(coords.lon(), coords.lat(), VectorDataSet.DUPE_NODE_DISTANCE));
         final VectorNode node;
         if (!nodes.isEmpty()) {
             final VectorNode first = nodes.iterator().next();
@@ -202,26 +209,29 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
         }
         node.setCoor(coords);
         featureObjects.add(node);
+        nodeMap.put(node.getCoor(), node);
         return node;
     }
 
     private <T extends Tile & VectorTile> List<VectorWay> pathToWay(T tile, Layer layer,
-      Collection<VectorPrimitive> featureObjects, Path2D shape) {
+      Collection<VectorPrimitive> featureObjects, Path2D shape, Map<ILatLon, VectorNode> nodeMap) {
         final PathIterator pathIterator = shape.getPathIterator(null);
-        final List<VectorWay> ways = pathIteratorToObjects(tile, layer, featureObjects, pathIterator).stream()
-          .filter(VectorWay.class::isInstance).map(VectorWay.class::cast).collect(toList());
+        final List<VectorWay> ways = new ArrayList<>(
+                Utils.filteredCollection(pathIteratorToObjects(tile, layer, featureObjects, pathIterator, nodeMap), VectorWay.class));
         // These nodes technically do not exist, so we shouldn't show them
-        ways.stream().flatMap(way -> way.getNodes().stream())
-          .filter(prim -> !prim.isTagged() && prim.getReferrers(true).size() == 1 && prim.getId() <= 0)
-          .forEach(prim -> {
-              prim.setDisabled(true);
-              prim.setVisible(false);
-          });
+        for (VectorWay way : ways) {
+            for (VectorNode node : way.getNodes()) {
+                if (!node.hasKeys() && node.getReferrers(true).size() == 1 && node.getId() <= 0) {
+                    node.setDisabled(true);
+                    node.setVisible(false);
+                }
+            }
+        }
         return ways;
     }
 
     private <T extends Tile & VectorTile> List<VectorPrimitive> pathIteratorToObjects(T tile, Layer layer,
-      Collection<VectorPrimitive> featureObjects, PathIterator pathIterator) {
+      Collection<VectorPrimitive> featureObjects, PathIterator pathIterator, Map<ILatLon, VectorNode> nodeMap) {
         final List<VectorNode> nodes = new ArrayList<>();
         final double[] coords = new double[6];
         final List<VectorPrimitive> ways = new ArrayList<>();
@@ -242,7 +252,7 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
                 nodes.clear();
             }
             if (PathIterator.SEG_MOVETO == type || PathIterator.SEG_LINETO == type) {
-                final VectorNode node = pointToNode(tile, layer, featureObjects, (int) coords[0], (int) coords[1]);
+                final VectorNode node = pointToNode(tile, layer, featureObjects, (int) coords[0], (int) coords[1], nodeMap);
                 nodes.add(node);
             } else if (PathIterator.SEG_CLOSE != type) {
                 // Vector Tiles only have MoveTo, LineTo, and ClosePath. Anything else is not supported at this time.
@@ -259,9 +269,9 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
     }
 
     private <T extends Tile & VectorTile> VectorRelation areaToRelation(T tile, Layer layer,
-      Collection<VectorPrimitive> featureObjects, Area area) {
+      Collection<VectorPrimitive> featureObjects, Area area, Map<ILatLon, VectorNode> nodeMap) {
         VectorRelation vectorRelation = new VectorRelation(layer.getName());
-        for (VectorPrimitive member : pathIteratorToObjects(tile, layer, featureObjects, area.getPathIterator(null))) {
+        for (VectorPrimitive member : pathIteratorToObjects(tile, layer, featureObjects, area.getPathIterator(null), nodeMap)) {
             final String role;
             if (member instanceof VectorWay && ((VectorWay) member).isClosed()) {
                 role = Geometry.isClockwise(((VectorWay) member).getNodes()) ? "outer" : "inner";
@@ -279,10 +289,13 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
      * @param <T> The tile type
      */
     public <T extends Tile & VectorTile> void addDataTile(T tile) {
+        // Using a map reduces the cost of addFeatureData from 2,715,158,632 bytes to 235,042,184 bytes (-91.3%)
+        // This was somewhat variant, with some runs being closer to ~560 MB (still -80%).
+        final Map<ILatLon, VectorNode> nodeMap = new HashMap<>();
         for (Layer layer : tile.getLayers()) {
             for (Feature feature : layer.getFeatures()) {
                 try {
-                    addFeatureData(tile, layer, feature);
+                    addFeatureData(tile, layer, feature, nodeMap);
                 } catch (IllegalArgumentException e) {
                     Logging.error("Cannot add vector data for feature {0} of tile {1}: {2}", feature, tile, e.getMessage());
                     Logging.error(e);
@@ -298,10 +311,13 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
                         .findAny().orElse(null)));
     }
 
-    private <T extends Tile & VectorTile> void addFeatureData(T tile, Layer layer, Feature feature) {
-        List<VectorPrimitive> featureObjects = new ArrayList<>();
-        List<VectorPrimitive> primaryFeatureObjects = feature.getGeometryObject().getShapes().stream()
-                .map(shape -> shapeToPrimaryFeatureObject(tile, layer, shape, featureObjects)).collect(toList());
+    private <T extends Tile & VectorTile> void addFeatureData(T tile, Layer layer, Feature feature, Map<ILatLon, VectorNode> nodeMap) {
+        // This will typically be larger than primaryFeatureObjects, but this at least avoids quite a few ArrayList#grow calls
+        List<VectorPrimitive> featureObjects = new ArrayList<>(feature.getGeometryObject().getShapes().size());
+        List<VectorPrimitive> primaryFeatureObjects = new ArrayList<>(featureObjects.size());
+        for (Shape shape : feature.getGeometryObject().getShapes()) {
+            primaryFeatureObjects.add(shapeToPrimaryFeatureObject(tile, layer, shape, featureObjects, nodeMap));
+        }
         final VectorPrimitive primitive;
         if (primaryFeatureObjects.size() == 1) {
             primitive = primaryFeatureObjects.get(0);
@@ -310,8 +326,11 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
             }
         } else if (!primaryFeatureObjects.isEmpty()) {
             VectorRelation relation = new VectorRelation(layer.getName());
-            primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim))
-              .forEach(relation::addRelationMember);
+            List<VectorRelationMember> members = new ArrayList<>(primaryFeatureObjects.size());
+            for (VectorPrimitive prim : primaryFeatureObjects) {
+                members.add(new VectorRelationMember("", prim));
+            }
+            relation.setMembers(members);
             primitive = relation;
         } else {
             return;
@@ -325,10 +344,14 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
             primitive.setId(primitive.getIdGenerator().generateUniqueId());
         }
         if (feature.getTags() != null) {
-            feature.getTags().forEach(primitive::put);
+            primitive.putAll(feature.getTags());
+        }
+        for (VectorPrimitive prim : featureObjects) {
+            this.addPrimitive(prim);
+        }
+        for (VectorPrimitive prim : primaryFeatureObjects) {
+            this.addPrimitive(prim);
         }
-        featureObjects.forEach(this::addPrimitive);
-        primaryFeatureObjects.forEach(this::addPrimitive);
         try {
             this.addPrimitive(primitive);
         } catch (JosmRuntimeException e) {
@@ -338,15 +361,15 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
     }
 
     private <T extends Tile & VectorTile> VectorPrimitive shapeToPrimaryFeatureObject(
-            T tile, Layer layer, Shape shape, List<VectorPrimitive> featureObjects) {
+            T tile, Layer layer, Shape shape, List<VectorPrimitive> featureObjects, Map<ILatLon, VectorNode> nodeMap) {
         final VectorPrimitive primitive;
         if (shape instanceof Ellipse2D) {
             primitive = pointToNode(tile, layer, featureObjects,
-                    (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY());
+                    (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY(), nodeMap);
         } else if (shape instanceof Path2D) {
-            primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape).stream().findFirst().orElse(null);
+            primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape, nodeMap).stream().findFirst().orElse(null);
         } else if (shape instanceof Area) {
-            primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
+            primitive = areaToRelation(tile, layer, featureObjects, (Area) shape, nodeMap);
             primitive.put(RELATION_TYPE, MULTIPOLYGON_TYPE);
         } else {
             // We shouldn't hit this, but just in case
diff --git a/src/org/openstreetmap/josm/data/vector/VectorNode.java b/src/org/openstreetmap/josm/data/vector/VectorNode.java
index a406d775b2..3e215a9477 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorNode.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorNode.java
@@ -25,6 +25,7 @@ public class VectorNode extends VectorPrimitive implements INode {
     private static final UniqueIdGenerator ID_GENERATOR = new UniqueIdGenerator();
     private double lon = Double.NaN;
     private double lat = Double.NaN;
+    private BBox cachedBbox;
 
     /**
      * Create a new vector node
@@ -56,8 +57,7 @@ public class VectorNode extends VectorPrimitive implements INode {
 
     @Override
     public void setCoor(LatLon coordinates) {
-        this.lat = coordinates.lat();
-        this.lon = coordinates.lon();
+        setCoor(coordinates.lat(), coordinates.lon());
     }
 
     /**
@@ -67,15 +67,19 @@ public class VectorNode extends VectorPrimitive implements INode {
      * @see #setCoor(LatLon)
      */
     public void setCoor(ICoordinate coordinates) {
-        this.lat = coordinates.getLat();
-        this.lon = coordinates.getLon();
+        setCoor(coordinates.getLat(), coordinates.getLon());
     }
 
     @Override
     public void setEastNorth(EastNorth eastNorth) {
         final LatLon ll = ProjectionRegistry.getProjection().eastNorth2latlon(eastNorth);
-        this.lat = ll.lat();
-        this.lon = ll.lon();
+        setCoor(ll);
+    }
+
+    private void setCoor(final double lat, final double lon) {
+        this.lat = lat;
+        this.lon = lon;
+        cachedBbox = null;
     }
 
     @Override
@@ -104,7 +108,19 @@ public class VectorNode extends VectorPrimitive implements INode {
 
     @Override
     public BBox getBBox() {
-        return new BBox(this.lon, this.lat).toImmutable();
+        // Don't make immutable -- we don't care (not a cached bbox), and making it immutable doubles the cost of this call.
+        // This is important since QuadBuckets#search ends up calling getBBox() repeatedly when doing a search.
+        // This really only matters during the construction of the Vector layers.
+        if (cachedBbox == null) {
+            updateCachedBBox();
+        }
+        return cachedBbox;
+    }
+
+    private synchronized void updateCachedBBox() {
+        if (cachedBbox == null) {
+            cachedBbox = new BBox(this.lon, this.lat).toImmutable();
+        }
     }
 
     @Override
diff --git a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
index 6e1fdb47d8..403abf3c32 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
@@ -2,6 +2,7 @@
 package org.openstreetmap.josm.data.vector;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Consumer;
@@ -117,6 +118,11 @@ public abstract class VectorPrimitive extends AbstractPrimitive implements DataL
 
     @Override
     public final List<VectorPrimitive> getReferrers(boolean allowWithoutDataset) {
+        if (this.referrers == null) {
+            return Collections.emptyList();
+        } else if (this.referrers instanceof VectorPrimitive) {
+            return Collections.singletonList((VectorPrimitive) this.referrers);
+        }
         return referrers(allowWithoutDataset, VectorPrimitive.class)
           .collect(Collectors.toList());
     }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelation.java b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
index 60ce48d2bb..36079097bd 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorRelation.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
@@ -90,6 +90,10 @@ public class VectorRelation extends VectorPrimitive implements IRelation<VectorR
     public void setMembers(List<VectorRelationMember> members) {
         this.members.clear();
         this.members.addAll(members);
+        for (VectorRelationMember member : members) {
+            member.getMember().addReferrer(this);
+        }
+        cachedBBox = null;
     }
 
     @Override
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
index f99aa7e2e0..1182151287 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
@@ -3,7 +3,7 @@ package org.openstreetmap.josm.data.protobuf;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
-
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 
 import org.junit.jupiter.api.Test;
@@ -15,7 +15,7 @@ class ProtobufRecordTest {
     @Test
     void testFixed32() throws IOException {
         ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
-        ProtobufRecord thirtyTwoBit = new ProtobufRecord(parser);
+        ProtobufRecord thirtyTwoBit = new ProtobufRecord(new ByteArrayOutputStream(), parser);
         assertEquals(WireType.THIRTY_TWO_BIT, thirtyTwoBit.getType());
         assertEquals(1f, thirtyTwoBit.asFloat());
     }
@@ -23,7 +23,7 @@ class ProtobufRecordTest {
     @Test
     void testUnknown() throws IOException {
         ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
-        ProtobufRecord unknown = new ProtobufRecord(parser);
+        ProtobufRecord unknown = new ProtobufRecord(new ByteArrayOutputStream(), parser);
         assertEquals(WireType.UNKNOWN, unknown.getType());
         assertEquals(0, unknown.getBytes().length);
     }
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
index 0844c6ebd9..7237e40c1c 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
@@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.fail;
 
 import java.awt.geom.Ellipse2D;
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -171,7 +172,7 @@ class ProtobufTest {
     @Test
     void testSimpleMessage() throws IOException {
         ProtobufParser parser = new ProtobufParser(new byte[] {(byte) 0x08, (byte) 0x96, (byte) 0x01});
-        ProtobufRecord record = new ProtobufRecord(parser);
+        ProtobufRecord record = new ProtobufRecord(new ByteArrayOutputStream(), parser);
         assertEquals(WireType.VARINT, record.getType());
         assertEquals(150, record.asUnsignedVarInt().intValue());
     }
